import { useEffect, useLayoutEffect, useState, useCallback, useRef, } from "react"; import { useNavigate } from "react-router-dom"; import { AlertTriangle, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Database, MessageSquare, Search, Trash2, Clock, Terminal, Globe, MessageCircle, Hash, X, Play, } from "lucide-react"; import { api } from "@/lib/api"; import type { SessionInfo, SessionMessage, SessionSearchResult, StatusResponse, } from "@/lib/api"; import { timeAgo } from "@/lib/utils"; import { Markdown } from "@/components/Markdown"; import { PlatformsCard } from "@/components/PlatformsCard"; import { Toast } from "@/components/Toast"; import { Button } from "@nous-research/ui/ui/components/button"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useConfirmDelete } from "@/hooks/useConfirmDelete"; import { Input } from "@/components/ui/input"; import { useSystemActions } from "@/contexts/useSystemActions"; import { useToast } from "@/hooks/useToast"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; const SOURCE_CONFIG: Record = { cli: { icon: Terminal, color: "text-primary" }, telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" }, discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" }, slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" }, whatsapp: { icon: Globe, color: "text-success" }, cron: { icon: Clock, color: "text-warning" }, }; /** Render an FTS5 snippet with highlighted matches. * The backend wraps matches in >>> and <<< delimiters. */ function SnippetHighlight({ snippet }: { snippet: string }) { const parts: React.ReactNode[] = []; const regex = />>>(.*?)<< last) { parts.push(snippet.slice(last, match.index)); } parts.push( {match[1]} , ); last = regex.lastIndex; } if (last < snippet.length) { parts.push(snippet.slice(last)); } return (

{parts}

); } function ToolCallBlock({ toolCall, }: { toolCall: { id: string; function: { name: string; arguments: string } }; }) { const [open, setOpen] = useState(false); const { t } = useI18n(); let args = toolCall.function.arguments; try { args = JSON.stringify(JSON.parse(args), null, 2); } catch { // keep as-is } return (
setOpen(!open)} aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`} aria-expanded={open} className="px-3 py-2 text-xs text-warning hover:bg-warning/10 hover:text-warning" > {open ? ( ) : ( )} {toolCall.function.name} {toolCall.id} {open && (
          {args}
        
)}
); } function MessageBubble({ msg, highlight, }: { msg: SessionMessage; highlight?: string; }) { const { t } = useI18n(); const ROLE_STYLES: Record< string, { bg: string; text: string; label: string } > = { user: { bg: "bg-primary/10", text: "text-primary", label: t.sessions.roles.user, }, assistant: { bg: "bg-success/10", text: "text-success", label: t.sessions.roles.assistant, }, system: { bg: "bg-muted", text: "text-muted-foreground", label: t.sessions.roles.system, }, tool: { bg: "bg-warning/10", text: "text-warning", label: t.sessions.roles.tool, }, }; const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system; const label = msg.tool_name ? `${t.sessions.roles.tool}: ${msg.tool_name}` : style.label; // Check if any search term appears as a prefix of any word in content const isHit = (() => { if (!highlight || !msg.content) return false; const content = msg.content.toLowerCase(); const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean); return terms.some((term) => content.includes(term)); })(); // Split search query into terms for inline highlighting const highlightTerms = isHit && highlight ? highlight.split(/\s+/).filter(Boolean) : undefined; return (
{label} {isHit && ( {t.common.match} )} {msg.timestamp && ( {timeAgo(msg.timestamp)} )}
{msg.content && (msg.role === "system" ? (
{msg.content}
) : ( ))} {msg.tool_calls && msg.tool_calls.length > 0 && (
{msg.tool_calls.map((tc) => ( ))}
)}
); } /** Message list with auto-scroll to first search hit. */ function MessageList({ messages, highlight, }: { messages: SessionMessage[]; highlight?: string; }) { const containerRef = useRef(null); useEffect(() => { if (!highlight || !containerRef.current) return; // Scroll to first hit after render const timer = setTimeout(() => { const hit = containerRef.current?.querySelector("[data-search-hit]"); if (hit) { hit.scrollIntoView({ behavior: "smooth", block: "center" }); } }, 50); return () => clearTimeout(timer); }, [messages, highlight]); return (
{messages.map((msg, i) => ( ))}
); } function SessionRow({ session, snippet, searchQuery, isExpanded, onToggle, onDelete, resumeInChatEnabled, }: { session: SessionInfo; snippet?: string; searchQuery?: string; isExpanded: boolean; onToggle: () => void; onDelete: () => void; resumeInChatEnabled: boolean; }) { const [messages, setMessages] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { t } = useI18n(); const navigate = useNavigate(); useEffect(() => { if (isExpanded && messages === null && !loading) { setLoading(true); api .getSessionMessages(session.id) .then((resp) => setMessages(resp.messages)) .catch((err) => setError(String(err))) .finally(() => setLoading(false)); } }, [isExpanded, session.id, messages, loading]); const sourceInfo = (session.source ? SOURCE_CONFIG[session.source] : null) ?? { icon: Globe, color: "text-muted-foreground" }; const SourceIcon = sourceInfo.icon; const hasTitle = session.title && session.title !== "Untitled"; return (
{hasTitle ? session.title : session.preview ? session.preview.slice(0, 60) : t.sessions.untitledSession} {session.is_active && ( {t.common.live} )}
{(session.model ?? t.common.unknown).split("/").pop()} · {session.message_count} {t.common.msgs} {session.tool_call_count > 0 && ( <> · {session.tool_call_count} {t.common.tools} )} · {timeAgo(session.last_active)}
{snippet && }
{session.source ?? "local"} {resumeInChatEnabled && ( )}
{isExpanded && (
{loading && (
)} {error && (

{error}

)} {messages && messages.length === 0 && (

{t.sessions.noMessages}

)} {messages && messages.length > 0 && ( )}
)}
); } export default function SessionsPage() { const [sessions, setSessions] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); const PAGE_SIZE = 20; const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [expandedId, setExpandedId] = useState(null); const [searchResults, setSearchResults] = useState< SessionSearchResult[] | null >(null); const [searching, setSearching] = useState(false); const debounceRef = useRef>(null); const logScrollRef = useRef(null); const [status, setStatus] = useState(null); const [overviewSessions, setOverviewSessions] = useState([]); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); const { activeAction, actionStatus, dismissLog } = useSystemActions(); const resumeInChatEnabled = isDashboardEmbeddedChatEnabled(); useLayoutEffect(() => { if (loading) { setAfterTitle(null); setEnd(null); return; } setAfterTitle( {total} , ); setEnd(
{searching ? ( ) : ( )} setSearch(e.target.value)} className="h-8 pr-7 pl-8 text-xs" /> {search && ( )}
, ); return () => { setAfterTitle(null); setEnd(null); }; }, [ loading, search, searching, setAfterTitle, setEnd, t.common.clear, t.sessions.searchPlaceholder, total, ]); const loadSessions = useCallback((p: number) => { setLoading(true); api .getSessions(PAGE_SIZE, p * PAGE_SIZE) .then((resp) => { setSessions(resp.sessions); setTotal(resp.total); }) .catch(() => {}) .finally(() => setLoading(false)); }, []); useEffect(() => { loadSessions(page); }, [loadSessions, page]); useEffect(() => { const loadOverview = () => { api .getStatus() .then(setStatus) .catch(() => {}); api .getSessions(50) .then((r) => setOverviewSessions(r.sessions)) .catch(() => {}); }; loadOverview(); const id = setInterval(loadOverview, 5000); return () => clearInterval(id); }, []); useEffect(() => { const el = logScrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [actionStatus?.lines]); // Debounced FTS search useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); if (!search.trim()) { setSearchResults(null); setSearching(false); return; } setSearching(true); debounceRef.current = setTimeout(() => { api .searchSessions(search.trim()) .then((resp) => setSearchResults(resp.results)) .catch(() => setSearchResults(null)) .finally(() => setSearching(false)); }, 300); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [search]); const sessionDelete = useConfirmDelete({ onDelete: useCallback( async (id: string) => { try { await api.deleteSession(id); setSessions((prev) => prev.filter((s) => s.id !== id)); setTotal((prev) => prev - 1); if (expandedId === id) setExpandedId(null); showToast(t.sessions.sessionDeleted, "success"); } catch { showToast(t.sessions.failedToDelete, "error"); throw new Error("delete failed"); } }, [ expandedId, showToast, t.sessions.sessionDeleted, t.sessions.failedToDelete, ], ), }); const pendingSession = sessionDelete.pendingId ? sessions.find((s) => s.id === sessionDelete.pendingId) : null; // Build snippet map from search results (session_id → snippet) const snippetMap = new Map(); if (searchResults) { for (const r of searchResults) { snippetMap.set(r.session_id, r.snippet); } } // When searching, filter sessions to those with FTS matches; // when not searching, show all sessions const filtered = searchResults ? sessions.filter((s) => snippetMap.has(s.id)) : sessions; const platformEntries = status ? Object.entries(status.gateway_platforms ?? {}) : []; const recentSessions = overviewSessions .filter((s) => !s.is_active) .slice(0, 5); const alerts: { message: string; detail?: string }[] = []; if (status) { if (status.gateway_state === "startup_failed") { alerts.push({ message: t.status.gatewayFailedToStart, detail: status.gateway_exit_reason ?? undefined, }); } const failedPlatformEntries = platformEntries.filter( ([, info]) => info.state === "fatal" || info.state === "disconnected", ); for (const [name, info] of failedPlatformEntries) { const stateLabel = info.state === "fatal" ? t.status.platformError : t.status.platformDisconnected; alerts.push({ message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`, detail: info.error_message ?? undefined, }); } } if (loading) { return (
); } return (
{alerts.length > 0 && (
{alerts.map((alert, i) => (

{alert.message}

{alert.detail && (

{alert.detail}

)}
))}
)} {activeAction && (
{actionStatus?.running ? ( ) : actionStatus?.exit_code === 0 ? ( ) : actionStatus !== null ? ( ) : ( )} {activeAction === "restart" ? t.status.restartGateway : t.status.updateHermes} {actionStatus?.running ? t.status.running : actionStatus?.exit_code === 0 ? t.status.actionFinished : actionStatus ? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})` : t.common.loading}
            {actionStatus?.lines && actionStatus.lines.length > 0
              ? actionStatus.lines.join("\n")
              : t.status.waitingForOutput}
          
)} {platformEntries.length > 0 && status && ( )} {recentSessions.length > 0 && (
{t.status.recentSessions}
{recentSessions.map((s) => (
{s.title ?? t.common.untitled} {(s.model ?? t.common.unknown).split("/").pop()} {" "} · {s.message_count} {t.common.msgs} ·{" "} {timeAgo(s.last_active)} {s.preview && ( {s.preview} )}
{s.source ?? "local"}
))}
)} {filtered.length === 0 ? (

{search ? t.sessions.noMatch : t.sessions.noSessions}

{!search && (

{t.sessions.startConversation}

)}
) : ( <>
{filtered.map((s) => ( setExpandedId((prev) => (prev === s.id ? null : s.id)) } onDelete={() => sessionDelete.requestDelete(s.id)} resumeInChatEnabled={resumeInChatEnabled} /> ))}
{!searchResults && total > PAGE_SIZE && (
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)}{" "} {t.common.of} {total}
{t.common.page} {page + 1} {t.common.of}{" "} {Math.ceil(total / PAGE_SIZE)}
)} )}
); }