session-list.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. 'use client';
  2. import type { ChatSession, SessionStatus } from '@/lib/types/chat';
  3. import { cn } from '@/lib/utils';
  4. import { useI18n } from '@/lib/hooks/use-i18n';
  5. import { ChevronDown, Circle, CheckCircle, Clock } from 'lucide-react';
  6. import { motion, AnimatePresence } from 'motion/react';
  7. import { ChatSessionComponent } from './chat-session';
  8. interface SessionListProps {
  9. sessions: ChatSession[];
  10. expandedSessionIds: Set<string>;
  11. isStreaming: boolean;
  12. activeBubbleId?: string | null;
  13. onToggleExpand: (sessionId: string) => void;
  14. onEndSession: (sessionId: string) => Promise<void>;
  15. }
  16. const sessionBadgeStyles = {
  17. qa: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
  18. discussion: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
  19. lecture: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
  20. };
  21. // Labels are provided via i18n in the component
  22. function getStatusIcon(status: SessionStatus) {
  23. switch (status) {
  24. case 'active':
  25. return <Circle className="size-2.5 fill-green-500 text-green-500" />;
  26. case 'interrupted':
  27. return <Clock className="size-2.5 text-yellow-500" />;
  28. case 'completed':
  29. return <CheckCircle className="size-2.5 text-gray-400" />;
  30. case 'idle':
  31. default:
  32. return <Circle className="size-2.5 text-gray-300" />;
  33. }
  34. }
  35. export function SessionList({
  36. sessions,
  37. expandedSessionIds,
  38. isStreaming,
  39. activeBubbleId,
  40. onToggleExpand,
  41. onEndSession,
  42. }: SessionListProps) {
  43. const { t } = useI18n();
  44. return (
  45. <>
  46. {sessions.map((session) => {
  47. const isExpanded = expandedSessionIds.has(session.id);
  48. const isActive = session.status === 'active';
  49. const dotColor =
  50. session.type === 'lecture'
  51. ? 'bg-purple-500'
  52. : session.type === 'qa'
  53. ? 'bg-blue-500'
  54. : 'bg-amber-500';
  55. return (
  56. <div
  57. key={session.id}
  58. className={cn(
  59. 'rounded-xl border transition-all duration-500 overflow-hidden',
  60. isActive
  61. ? 'border-purple-200 dark:border-purple-700 bg-purple-50/30 dark:bg-purple-900/20 shadow-sm'
  62. : 'border-gray-100 dark:border-gray-800 bg-white/50 dark:bg-gray-800/50',
  63. )}
  64. >
  65. {/* Session Header */}
  66. <button
  67. onClick={() => onToggleExpand(session.id)}
  68. className="w-full flex items-center gap-1.5 px-3 py-2 text-left hover:bg-gray-50/50 dark:hover:bg-gray-800/50 transition-colors"
  69. >
  70. <span className="relative flex h-2.5 w-2.5 shrink-0">
  71. <span className={cn(dotColor, 'relative inline-flex rounded-full h-2.5 w-2.5')} />
  72. {isActive && (
  73. <span
  74. className={cn(
  75. dotColor,
  76. 'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75',
  77. )}
  78. />
  79. )}
  80. </span>
  81. <span
  82. className={cn(
  83. 'text-[8px] font-extrabold uppercase tracking-wider px-1.5 py-px rounded shrink-0',
  84. sessionBadgeStyles[session.type],
  85. )}
  86. >
  87. {t(`chat.badge.${session.type}`)}
  88. </span>
  89. <span className="flex-1 text-[11px] font-semibold text-gray-700 dark:text-gray-300 truncate">
  90. {session.title}
  91. </span>
  92. <div className="flex items-center gap-1 text-[9px] text-gray-400 dark:text-gray-500">
  93. {getStatusIcon(session.status)}
  94. </div>
  95. <span className="text-[9px] text-gray-400 dark:text-gray-500 font-medium tabular-nums shrink-0">
  96. {session.messages.length}
  97. </span>
  98. <ChevronDown
  99. className={cn(
  100. 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500 transition-transform duration-200 shrink-0',
  101. !isExpanded && '-rotate-90',
  102. )}
  103. />
  104. </button>
  105. {/* Messages */}
  106. <AnimatePresence initial={false}>
  107. {isExpanded && (
  108. <motion.div
  109. initial={{ height: 0, opacity: 0 }}
  110. animate={{ height: 'auto', opacity: 1 }}
  111. exit={{ height: 0, opacity: 0 }}
  112. transition={{ duration: 0.2, ease: 'easeInOut' }}
  113. className="overflow-hidden border-t border-gray-100/50 dark:border-gray-700/50"
  114. >
  115. <div className="px-2 pb-2 pt-1">
  116. <ChatSessionComponent
  117. session={session}
  118. isActive={isActive}
  119. isStreaming={isStreaming && isActive}
  120. activeBubbleId={activeBubbleId}
  121. onEndSession={onEndSession}
  122. />
  123. </div>
  124. </motion.div>
  125. )}
  126. </AnimatePresence>
  127. </div>
  128. );
  129. })}
  130. </>
  131. );
  132. }