SessionsPage.tsx
1 import { 2 useEffect, 3 useLayoutEffect, 4 useState, 5 useCallback, 6 useRef, 7 } from "react"; 8 import { useNavigate } from "react-router-dom"; 9 import { 10 AlertTriangle, 11 CheckCircle2, 12 ChevronDown, 13 ChevronLeft, 14 ChevronRight, 15 Database, 16 MessageSquare, 17 Search, 18 Trash2, 19 Clock, 20 Terminal, 21 Globe, 22 MessageCircle, 23 Hash, 24 X, 25 Play, 26 } from "lucide-react"; 27 import { api } from "@/lib/api"; 28 import type { 29 SessionInfo, 30 SessionMessage, 31 SessionSearchResult, 32 StatusResponse, 33 } from "@/lib/api"; 34 import { timeAgo } from "@/lib/utils"; 35 import { Markdown } from "@/components/Markdown"; 36 import { PlatformsCard } from "@/components/PlatformsCard"; 37 import { Toast } from "@/components/Toast"; 38 import { Button } from "@nous-research/ui/ui/components/button"; 39 import { ListItem } from "@nous-research/ui/ui/components/list-item"; 40 import { Spinner } from "@nous-research/ui/ui/components/spinner"; 41 import { Badge } from "@nous-research/ui/ui/components/badge"; 42 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 43 import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; 44 import { useConfirmDelete } from "@/hooks/useConfirmDelete"; 45 import { Input } from "@/components/ui/input"; 46 import { useSystemActions } from "@/contexts/useSystemActions"; 47 import { useToast } from "@/hooks/useToast"; 48 import { useI18n } from "@/i18n"; 49 import { usePageHeader } from "@/contexts/usePageHeader"; 50 import { PluginSlot } from "@/plugins"; 51 import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; 52 53 const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = 54 { 55 cli: { icon: Terminal, color: "text-primary" }, 56 telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" }, 57 discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" }, 58 slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" }, 59 whatsapp: { icon: Globe, color: "text-success" }, 60 cron: { icon: Clock, color: "text-warning" }, 61 }; 62 63 /** Render an FTS5 snippet with highlighted matches. 64 * The backend wraps matches in >>> and <<< delimiters. */ 65 function SnippetHighlight({ snippet }: { snippet: string }) { 66 const parts: React.ReactNode[] = []; 67 const regex = />>>(.*?)<<</g; 68 let last = 0; 69 let match: RegExpExecArray | null; 70 let i = 0; 71 while ((match = regex.exec(snippet)) !== null) { 72 if (match.index > last) { 73 parts.push(snippet.slice(last, match.index)); 74 } 75 parts.push( 76 <mark key={i++} className="bg-warning/30 text-warning px-0.5"> 77 {match[1]} 78 </mark>, 79 ); 80 last = regex.lastIndex; 81 } 82 if (last < snippet.length) { 83 parts.push(snippet.slice(last)); 84 } 85 return ( 86 <p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5"> 87 {parts} 88 </p> 89 ); 90 } 91 92 function ToolCallBlock({ 93 toolCall, 94 }: { 95 toolCall: { id: string; function: { name: string; arguments: string } }; 96 }) { 97 const [open, setOpen] = useState(false); 98 const { t } = useI18n(); 99 100 let args = toolCall.function.arguments; 101 try { 102 args = JSON.stringify(JSON.parse(args), null, 2); 103 } catch { 104 // keep as-is 105 } 106 107 return ( 108 <div className="mt-2 border border-warning/20 bg-warning/5"> 109 <ListItem 110 onClick={() => setOpen(!open)} 111 aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`} 112 aria-expanded={open} 113 className="px-3 py-2 text-xs text-warning hover:bg-warning/10 hover:text-warning" 114 > 115 {open ? ( 116 <ChevronDown className="h-3 w-3" /> 117 ) : ( 118 <ChevronRight className="h-3 w-3" /> 119 )} 120 <span className="font-mono-ui font-medium"> 121 {toolCall.function.name} 122 </span> 123 <span className="text-warning/50 ml-auto">{toolCall.id}</span> 124 </ListItem> 125 {open && ( 126 <pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono"> 127 {args} 128 </pre> 129 )} 130 </div> 131 ); 132 } 133 134 function MessageBubble({ 135 msg, 136 highlight, 137 }: { 138 msg: SessionMessage; 139 highlight?: string; 140 }) { 141 const { t } = useI18n(); 142 143 const ROLE_STYLES: Record< 144 string, 145 { bg: string; text: string; label: string } 146 > = { 147 user: { 148 bg: "bg-primary/10", 149 text: "text-primary", 150 label: t.sessions.roles.user, 151 }, 152 assistant: { 153 bg: "bg-success/10", 154 text: "text-success", 155 label: t.sessions.roles.assistant, 156 }, 157 system: { 158 bg: "bg-muted", 159 text: "text-muted-foreground", 160 label: t.sessions.roles.system, 161 }, 162 tool: { 163 bg: "bg-warning/10", 164 text: "text-warning", 165 label: t.sessions.roles.tool, 166 }, 167 }; 168 169 const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system; 170 const label = msg.tool_name 171 ? `${t.sessions.roles.tool}: ${msg.tool_name}` 172 : style.label; 173 174 // Check if any search term appears as a prefix of any word in content 175 const isHit = (() => { 176 if (!highlight || !msg.content) return false; 177 const content = msg.content.toLowerCase(); 178 const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean); 179 return terms.some((term) => content.includes(term)); 180 })(); 181 182 // Split search query into terms for inline highlighting 183 const highlightTerms = 184 isHit && highlight ? highlight.split(/\s+/).filter(Boolean) : undefined; 185 186 return ( 187 <div 188 className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`} 189 data-search-hit={isHit || undefined} 190 > 191 <div className="flex items-center gap-2 mb-1"> 192 <span className={`text-xs font-semibold ${style.text}`}>{label}</span> 193 {isHit && ( 194 <Badge tone="warning" className="text-[9px] py-0 px-1.5"> 195 {t.common.match} 196 </Badge> 197 )} 198 {msg.timestamp && ( 199 <span className="text-[10px] text-muted-foreground"> 200 {timeAgo(msg.timestamp)} 201 </span> 202 )} 203 </div> 204 {msg.content && 205 (msg.role === "system" ? ( 206 <div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed"> 207 {msg.content} 208 </div> 209 ) : ( 210 <Markdown content={msg.content} highlightTerms={highlightTerms} /> 211 ))} 212 {msg.tool_calls && msg.tool_calls.length > 0 && ( 213 <div className="mt-1"> 214 {msg.tool_calls.map((tc) => ( 215 <ToolCallBlock key={tc.id} toolCall={tc} /> 216 ))} 217 </div> 218 )} 219 </div> 220 ); 221 } 222 223 /** Message list with auto-scroll to first search hit. */ 224 function MessageList({ 225 messages, 226 highlight, 227 }: { 228 messages: SessionMessage[]; 229 highlight?: string; 230 }) { 231 const containerRef = useRef<HTMLDivElement>(null); 232 233 useEffect(() => { 234 if (!highlight || !containerRef.current) return; 235 // Scroll to first hit after render 236 const timer = setTimeout(() => { 237 const hit = containerRef.current?.querySelector("[data-search-hit]"); 238 if (hit) { 239 hit.scrollIntoView({ behavior: "smooth", block: "center" }); 240 } 241 }, 50); 242 return () => clearTimeout(timer); 243 }, [messages, highlight]); 244 245 return ( 246 <div 247 ref={containerRef} 248 className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2" 249 > 250 {messages.map((msg, i) => ( 251 <MessageBubble key={i} msg={msg} highlight={highlight} /> 252 ))} 253 </div> 254 ); 255 } 256 257 function SessionRow({ 258 session, 259 snippet, 260 searchQuery, 261 isExpanded, 262 onToggle, 263 onDelete, 264 resumeInChatEnabled, 265 }: { 266 session: SessionInfo; 267 snippet?: string; 268 searchQuery?: string; 269 isExpanded: boolean; 270 onToggle: () => void; 271 onDelete: () => void; 272 resumeInChatEnabled: boolean; 273 }) { 274 const [messages, setMessages] = useState<SessionMessage[] | null>(null); 275 const [loading, setLoading] = useState(false); 276 const [error, setError] = useState<string | null>(null); 277 const { t } = useI18n(); 278 const navigate = useNavigate(); 279 280 useEffect(() => { 281 if (isExpanded && messages === null && !loading) { 282 setLoading(true); 283 api 284 .getSessionMessages(session.id) 285 .then((resp) => setMessages(resp.messages)) 286 .catch((err) => setError(String(err))) 287 .finally(() => setLoading(false)); 288 } 289 }, [isExpanded, session.id, messages, loading]); 290 291 const sourceInfo = (session.source 292 ? SOURCE_CONFIG[session.source] 293 : null) ?? { icon: Globe, color: "text-muted-foreground" }; 294 const SourceIcon = sourceInfo.icon; 295 const hasTitle = session.title && session.title !== "Untitled"; 296 297 return ( 298 <div 299 className={`border overflow-hidden transition-colors ${ 300 session.is_active 301 ? "border-success/30 bg-success/[0.03]" 302 : "border-border" 303 }`} 304 > 305 <div 306 className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors" 307 onClick={onToggle} 308 > 309 <div className="flex items-center gap-3 min-w-0 flex-1"> 310 <div className={`shrink-0 ${sourceInfo.color}`}> 311 <SourceIcon className="h-4 w-4" /> 312 </div> 313 <div className="flex flex-col gap-0.5 min-w-0"> 314 <div className="flex items-center gap-2"> 315 <span 316 className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`} 317 > 318 {hasTitle 319 ? session.title 320 : session.preview 321 ? session.preview.slice(0, 60) 322 : t.sessions.untitledSession} 323 </span> 324 {session.is_active && ( 325 <Badge tone="success" className="text-[10px] shrink-0"> 326 <span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" /> 327 {t.common.live} 328 </Badge> 329 )} 330 </div> 331 <div className="flex items-center gap-1.5 text-xs text-muted-foreground"> 332 <span className="truncate max-w-[120px] sm:max-w-[180px]"> 333 {(session.model ?? t.common.unknown).split("/").pop()} 334 </span> 335 <span className="text-border">·</span> 336 <span> 337 {session.message_count} {t.common.msgs} 338 </span> 339 {session.tool_call_count > 0 && ( 340 <> 341 <span className="text-border">·</span> 342 <span> 343 {session.tool_call_count} {t.common.tools} 344 </span> 345 </> 346 )} 347 <span className="text-border">·</span> 348 <span>{timeAgo(session.last_active)}</span> 349 </div> 350 {snippet && <SnippetHighlight snippet={snippet} />} 351 </div> 352 </div> 353 354 <div className="flex items-center gap-2 shrink-0"> 355 <Badge tone="outline" className="text-[10px]"> 356 {session.source ?? "local"} 357 </Badge> 358 {resumeInChatEnabled && ( 359 <Button 360 ghost 361 size="icon" 362 className="text-muted-foreground hover:text-success" 363 aria-label={t.sessions.resumeInChat} 364 title={t.sessions.resumeInChat} 365 onClick={(e) => { 366 e.stopPropagation(); 367 navigate(`/chat?resume=${encodeURIComponent(session.id)}`); 368 }} 369 > 370 <Play /> 371 </Button> 372 )} 373 <Button 374 ghost 375 destructive 376 size="icon" 377 aria-label={t.sessions.deleteSession} 378 onClick={(e) => { 379 e.stopPropagation(); 380 onDelete(); 381 }} 382 > 383 <Trash2 /> 384 </Button> 385 </div> 386 </div> 387 388 {isExpanded && ( 389 <div className="border-t border-border bg-background/50 p-4"> 390 {loading && ( 391 <div className="flex items-center justify-center py-8"> 392 <Spinner className="text-xl text-primary" /> 393 </div> 394 )} 395 {error && ( 396 <p className="text-sm text-destructive py-4 text-center">{error}</p> 397 )} 398 {messages && messages.length === 0 && ( 399 <p className="text-sm text-muted-foreground py-4 text-center"> 400 {t.sessions.noMessages} 401 </p> 402 )} 403 {messages && messages.length > 0 && ( 404 <MessageList messages={messages} highlight={searchQuery} /> 405 )} 406 </div> 407 )} 408 </div> 409 ); 410 } 411 412 export default function SessionsPage() { 413 const [sessions, setSessions] = useState<SessionInfo[]>([]); 414 const [total, setTotal] = useState(0); 415 const [page, setPage] = useState(0); 416 const PAGE_SIZE = 20; 417 const [loading, setLoading] = useState(true); 418 const [search, setSearch] = useState(""); 419 const [expandedId, setExpandedId] = useState<string | null>(null); 420 const [searchResults, setSearchResults] = useState< 421 SessionSearchResult[] | null 422 >(null); 423 const [searching, setSearching] = useState(false); 424 const debounceRef = useRef<ReturnType<typeof setTimeout>>(null); 425 const logScrollRef = useRef<HTMLPreElement | null>(null); 426 const [status, setStatus] = useState<StatusResponse | null>(null); 427 const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]); 428 const { toast, showToast } = useToast(); 429 const { t } = useI18n(); 430 const { setAfterTitle, setEnd } = usePageHeader(); 431 const { activeAction, actionStatus, dismissLog } = useSystemActions(); 432 const resumeInChatEnabled = isDashboardEmbeddedChatEnabled(); 433 434 useLayoutEffect(() => { 435 if (loading) { 436 setAfterTitle(null); 437 setEnd(null); 438 return; 439 } 440 setAfterTitle( 441 <Badge tone="secondary" className="text-xs tabular-nums"> 442 {total} 443 </Badge>, 444 ); 445 setEnd( 446 <div className="relative w-full min-w-0 sm:max-w-xs"> 447 {searching ? ( 448 <Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" /> 449 ) : ( 450 <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> 451 )} 452 <Input 453 placeholder={t.sessions.searchPlaceholder} 454 value={search} 455 onChange={(e) => setSearch(e.target.value)} 456 className="h-8 pr-7 pl-8 text-xs" 457 /> 458 {search && ( 459 <Button 460 ghost 461 size="xs" 462 className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 463 onClick={() => setSearch("")} 464 aria-label={t.common.clear} 465 > 466 <X /> 467 </Button> 468 )} 469 </div>, 470 ); 471 return () => { 472 setAfterTitle(null); 473 setEnd(null); 474 }; 475 }, [ 476 loading, 477 search, 478 searching, 479 setAfterTitle, 480 setEnd, 481 t.common.clear, 482 t.sessions.searchPlaceholder, 483 total, 484 ]); 485 486 const loadSessions = useCallback((p: number) => { 487 setLoading(true); 488 api 489 .getSessions(PAGE_SIZE, p * PAGE_SIZE) 490 .then((resp) => { 491 setSessions(resp.sessions); 492 setTotal(resp.total); 493 }) 494 .catch(() => {}) 495 .finally(() => setLoading(false)); 496 }, []); 497 498 useEffect(() => { 499 loadSessions(page); 500 }, [loadSessions, page]); 501 502 useEffect(() => { 503 const loadOverview = () => { 504 api 505 .getStatus() 506 .then(setStatus) 507 .catch(() => {}); 508 api 509 .getSessions(50) 510 .then((r) => setOverviewSessions(r.sessions)) 511 .catch(() => {}); 512 }; 513 loadOverview(); 514 const id = setInterval(loadOverview, 5000); 515 return () => clearInterval(id); 516 }, []); 517 518 useEffect(() => { 519 const el = logScrollRef.current; 520 if (el) el.scrollTop = el.scrollHeight; 521 }, [actionStatus?.lines]); 522 523 // Debounced FTS search 524 useEffect(() => { 525 if (debounceRef.current) clearTimeout(debounceRef.current); 526 527 if (!search.trim()) { 528 setSearchResults(null); 529 setSearching(false); 530 return; 531 } 532 533 setSearching(true); 534 debounceRef.current = setTimeout(() => { 535 api 536 .searchSessions(search.trim()) 537 .then((resp) => setSearchResults(resp.results)) 538 .catch(() => setSearchResults(null)) 539 .finally(() => setSearching(false)); 540 }, 300); 541 542 return () => { 543 if (debounceRef.current) clearTimeout(debounceRef.current); 544 }; 545 }, [search]); 546 547 const sessionDelete = useConfirmDelete({ 548 onDelete: useCallback( 549 async (id: string) => { 550 try { 551 await api.deleteSession(id); 552 setSessions((prev) => prev.filter((s) => s.id !== id)); 553 setTotal((prev) => prev - 1); 554 if (expandedId === id) setExpandedId(null); 555 showToast(t.sessions.sessionDeleted, "success"); 556 } catch { 557 showToast(t.sessions.failedToDelete, "error"); 558 throw new Error("delete failed"); 559 } 560 }, 561 [ 562 expandedId, 563 showToast, 564 t.sessions.sessionDeleted, 565 t.sessions.failedToDelete, 566 ], 567 ), 568 }); 569 570 const pendingSession = sessionDelete.pendingId 571 ? sessions.find((s) => s.id === sessionDelete.pendingId) 572 : null; 573 574 // Build snippet map from search results (session_id → snippet) 575 const snippetMap = new Map<string, string>(); 576 if (searchResults) { 577 for (const r of searchResults) { 578 snippetMap.set(r.session_id, r.snippet); 579 } 580 } 581 582 // When searching, filter sessions to those with FTS matches; 583 // when not searching, show all sessions 584 const filtered = searchResults 585 ? sessions.filter((s) => snippetMap.has(s.id)) 586 : sessions; 587 588 const platformEntries = status 589 ? Object.entries(status.gateway_platforms ?? {}) 590 : []; 591 const recentSessions = overviewSessions 592 .filter((s) => !s.is_active) 593 .slice(0, 5); 594 595 const alerts: { message: string; detail?: string }[] = []; 596 if (status) { 597 if (status.gateway_state === "startup_failed") { 598 alerts.push({ 599 message: t.status.gatewayFailedToStart, 600 detail: status.gateway_exit_reason ?? undefined, 601 }); 602 } 603 const failedPlatformEntries = platformEntries.filter( 604 ([, info]) => info.state === "fatal" || info.state === "disconnected", 605 ); 606 for (const [name, info] of failedPlatformEntries) { 607 const stateLabel = 608 info.state === "fatal" 609 ? t.status.platformError 610 : t.status.platformDisconnected; 611 alerts.push({ 612 message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`, 613 detail: info.error_message ?? undefined, 614 }); 615 } 616 } 617 618 if (loading) { 619 return ( 620 <div className="flex items-center justify-center py-24"> 621 <Spinner className="text-2xl text-primary" /> 622 </div> 623 ); 624 } 625 626 return ( 627 <div className="flex flex-col gap-4"> 628 <PluginSlot name="sessions:top" /> 629 <Toast toast={toast} /> 630 631 <DeleteConfirmDialog 632 open={sessionDelete.isOpen} 633 onCancel={sessionDelete.cancel} 634 onConfirm={sessionDelete.confirm} 635 title={t.sessions.confirmDeleteTitle} 636 description={ 637 pendingSession?.title && pendingSession.title !== "Untitled" 638 ? `"${pendingSession.title}" — ${t.sessions.confirmDeleteMessage}` 639 : t.sessions.confirmDeleteMessage 640 } 641 loading={sessionDelete.isDeleting} 642 /> 643 644 {alerts.length > 0 && ( 645 <div className="border border-destructive/30 bg-destructive/[0.06] p-4"> 646 <div className="flex items-start gap-3"> 647 <AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" /> 648 <div className="flex flex-col gap-2 min-w-0"> 649 {alerts.map((alert, i) => ( 650 <div key={i}> 651 <p className="text-sm font-medium text-destructive"> 652 {alert.message} 653 </p> 654 {alert.detail && ( 655 <p className="text-xs text-destructive/70 mt-0.5"> 656 {alert.detail} 657 </p> 658 )} 659 </div> 660 ))} 661 </div> 662 </div> 663 </div> 664 )} 665 666 {activeAction && ( 667 <div className="border border-border bg-background-base/50"> 668 <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2"> 669 <div className="flex items-center gap-2 min-w-0"> 670 {actionStatus?.running ? ( 671 <Spinner className="shrink-0 text-[0.875rem] text-warning" /> 672 ) : actionStatus?.exit_code === 0 ? ( 673 <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" /> 674 ) : actionStatus !== null ? ( 675 <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" /> 676 ) : ( 677 <Spinner className="shrink-0 text-[0.875rem] text-muted-foreground" /> 678 )} 679 680 <span className="text-xs font-mondwest tracking-[0.12em] truncate"> 681 {activeAction === "restart" 682 ? t.status.restartGateway 683 : t.status.updateHermes} 684 </span> 685 686 <Badge 687 tone={ 688 actionStatus?.running 689 ? "warning" 690 : actionStatus?.exit_code === 0 691 ? "success" 692 : actionStatus 693 ? "destructive" 694 : "outline" 695 } 696 className="text-[10px] shrink-0" 697 > 698 {actionStatus?.running 699 ? t.status.running 700 : actionStatus?.exit_code === 0 701 ? t.status.actionFinished 702 : actionStatus 703 ? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})` 704 : t.common.loading} 705 </Badge> 706 </div> 707 708 <Button 709 ghost 710 size="icon" 711 onClick={dismissLog} 712 className="shrink-0 opacity-60 hover:opacity-100" 713 aria-label={t.common.close} 714 > 715 <X /> 716 </Button> 717 </div> 718 719 <pre 720 ref={logScrollRef} 721 className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all" 722 > 723 {actionStatus?.lines && actionStatus.lines.length > 0 724 ? actionStatus.lines.join("\n") 725 : t.status.waitingForOutput} 726 </pre> 727 </div> 728 )} 729 730 {platformEntries.length > 0 && status && ( 731 <PlatformsCard platforms={platformEntries} /> 732 )} 733 734 {recentSessions.length > 0 && ( 735 <Card> 736 <CardHeader> 737 <div className="flex items-center gap-2"> 738 <Clock className="h-5 w-5 text-muted-foreground" /> 739 <CardTitle className="text-base"> 740 {t.status.recentSessions} 741 </CardTitle> 742 </div> 743 </CardHeader> 744 745 <CardContent className="grid gap-3"> 746 {recentSessions.map((s) => ( 747 <div 748 key={s.id} 749 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full" 750 > 751 <div className="flex flex-col gap-1 min-w-0 w-full"> 752 <span className="font-medium text-sm truncate"> 753 {s.title ?? t.common.untitled} 754 </span> 755 756 <span className="text-xs text-muted-foreground truncate"> 757 <span className="font-mono-ui"> 758 {(s.model ?? t.common.unknown).split("/").pop()} 759 </span>{" "} 760 · {s.message_count} {t.common.msgs} ·{" "} 761 {timeAgo(s.last_active)} 762 </span> 763 764 {s.preview && ( 765 <span className="text-xs text-muted-foreground/70 truncate"> 766 {s.preview} 767 </span> 768 )} 769 </div> 770 771 <Badge 772 tone="outline" 773 className="text-[10px] shrink-0 self-start sm:self-center" 774 > 775 <Database className="mr-1 h-3 w-3" /> 776 {s.source ?? "local"} 777 </Badge> 778 </div> 779 ))} 780 </CardContent> 781 </Card> 782 )} 783 784 {filtered.length === 0 ? ( 785 <div className="flex flex-col items-center justify-center py-16 text-muted-foreground"> 786 <Clock className="h-8 w-8 mb-3 opacity-40" /> 787 <p className="text-sm font-medium"> 788 {search ? t.sessions.noMatch : t.sessions.noSessions} 789 </p> 790 {!search && ( 791 <p className="text-xs mt-1 text-muted-foreground/60"> 792 {t.sessions.startConversation} 793 </p> 794 )} 795 </div> 796 ) : ( 797 <> 798 <div className="flex flex-col gap-1.5"> 799 {filtered.map((s) => ( 800 <SessionRow 801 key={s.id} 802 session={s} 803 snippet={snippetMap.get(s.id)} 804 searchQuery={search || undefined} 805 isExpanded={expandedId === s.id} 806 onToggle={() => 807 setExpandedId((prev) => (prev === s.id ? null : s.id)) 808 } 809 onDelete={() => sessionDelete.requestDelete(s.id)} 810 resumeInChatEnabled={resumeInChatEnabled} 811 /> 812 ))} 813 </div> 814 815 {!searchResults && total > PAGE_SIZE && ( 816 <div className="flex items-center justify-between pt-2"> 817 <span className="text-xs text-muted-foreground"> 818 {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)}{" "} 819 {t.common.of} {total} 820 </span> 821 <div className="flex items-center gap-1"> 822 <Button 823 outlined 824 size="icon" 825 disabled={page === 0} 826 onClick={() => setPage((p) => p - 1)} 827 aria-label={t.sessions.previousPage} 828 > 829 <ChevronLeft /> 830 </Button> 831 <span className="text-xs text-muted-foreground px-2"> 832 {t.common.page} {page + 1} {t.common.of}{" "} 833 {Math.ceil(total / PAGE_SIZE)} 834 </span> 835 <Button 836 outlined 837 size="icon" 838 disabled={(page + 1) * PAGE_SIZE >= total} 839 onClick={() => setPage((p) => p + 1)} 840 aria-label={t.sessions.nextPage} 841 > 842 <ChevronRight /> 843 </Button> 844 </div> 845 </div> 846 )} 847 </> 848 )} 849 <PluginSlot name="sessions:bottom" /> 850 </div> 851 ); 852 }