/** * @file Sessions.tsx * @description Displays a list of all recorded sessions with filtering, searching, and pagination features; a row's right-click opens `SessionRowMenu`. Sessions are updated in real-time based on events received from the event bus. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/dataScope` * - `../components/StatusBadge` * - `../components/EmptyState` * - `../components/Skeleton` * - `../lib/format` * - `../lib/types` * * ## Public surface * - `Sessions` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **Sessions** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useMemo, useRef, useState, useCallback, useSyncExternalStore } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { FolderOpen, Search, ChevronRight, RefreshCw, SortDesc, SortAsc, Play, Server, Download, AlertCircle, } from "lucide-react"; import { api } from "../lib/api"; import type { RemoteSource, RunHandle } from "../lib/api"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { useAiTools } from "../hooks/useAiTools"; import { useLanes } from "../hooks/useLanes"; import { toast } from "../lib/toast"; import { downloadText, exportName, toCsv } from "../lib/exportData"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useDataScope } from "../lib/dataScope"; import { SessionStatusBadge } from "../components/StatusBadge"; import { SessionToolBadge } from "../components/SessionToolBadge"; import { EmptyState } from "../components/EmptyState"; import { TableRowSkeleton } from "../components/Skeleton"; import { useContextMenu } from "../components/ContextMenu"; import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu"; import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format"; import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types"; import type { Session, DashboardEvent } from "../lib/types"; const PAGE_SIZE = 10; export function Sessions({ embedded = false }: { embedded?: boolean } = {}) { const navigate = useNavigate(); const rowCtx = useContextMenu(); const { t } = useTranslation("sessions"); const { tools } = useAiTools(); const { lanes } = useLanes(); // Filters, sort and page live in the URL (replace, no history spam), so a // reload, the back button and a shared link all land on the same view. const [params, setParams] = useSearchParams(); const filter = params.get("status") ?? ""; const search = params.get("q") ?? ""; const cwd = params.get("cwd") ?? ""; const tool = params.get("tool") ?? ""; const lane = params.get("lane") ?? ""; const from = params.get("from") ?? ""; const to = params.get("to") ?? ""; const sortBy = params.get("sort") ?? "time"; const sortDesc = params.get("asc") !== "1"; const page = Math.max(0, parseInt(params.get("page") ?? "0", 10) || 0); /** Writes params; any change but the page itself goes back to page 0 in the * SAME update, so one filter change is one load. */ const update = useCallback( (patch: Record) => setParams( (prev) => { const next = new URLSearchParams(prev); for (const [k, v] of Object.entries(patch)) { if (v) next.set(k, v); else next.delete(k); } if (!("page" in patch)) next.delete("page"); return next; }, { replace: true } ), [setParams] ); const setPage = (fn: (p: number) => number) => update({ page: String(fn(page) || "") }); const [sessions, setSessions] = useState([]); const [total, setTotal] = useState(0); // `searchInput` is what the user types; `q` in the URL is the debounced value // actually sent to the server. const [searchInput, setSearchInput] = useState(search); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [exporting, setExporting] = useState(false); const [directories, setDirectories] = useState([]); // Global data scope (which source machines to show). Included in `load`'s deps // so switching scope re-fetches; the actual `sources` param is injected by the // api layer (see lib/api.ts applyScope). const [scope] = useDataScope(); // source id → label, so remote-origin rows show a friendly badge. const [sourceLabels, setSourceLabels] = useState>(() => new Map()); // Live dashboard runs, so a row a pane holds links straight to that pane. const [runs, setRuns] = useState([]); const FILTER_OPTIONS: Array<{ label: string; value: string }> = [ { label: t("filterAll"), value: "" }, { label: t("filterActive"), value: "active" }, { label: t("filterWaiting"), value: "waiting" }, { label: t("filterCompleted"), value: "completed" }, { label: t("filterError"), value: "error" }, { label: t("filterAbandoned"), value: "abandoned" }, ]; // Debounce the search input → 300 ms after the user stops typing, the // committed value flips and triggers a fresh fetch. useEffect(() => { const id = window.setTimeout(() => { if (searchInput.trim() !== search) update({ q: searchInput.trim() || null }); }, 300); return () => window.clearTimeout(id); }, [searchInput, search, update]); // `?q=` changed from outside (a link to plain /sessions, back/forward): the // box follows, so the debounce above never writes the old text back. useEffect(() => { setSearchInput((cur) => (cur.trim() === search ? cur : search)); }, [search]); useEffect(() => { api.sessions .facets() .then((res) => { setDirectories(res.cwds); }) .catch(console.error); }, []); // Load remote-source labels so remote-origin rows can show a friendly badge // instead of a raw `src_…` id. Refreshed when a source's status changes. const loadSourceLabels = useCallback(() => { api.remoteSources .list() .then((res: { sources: RemoteSource[] }) => { setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label]))); }) .catch(() => undefined); }, []); useEffect(() => { loadSourceLabels(); }, [loadSourceLabels]); /** The list query for the current filters (paging added by the caller). */ const query = useMemo( () => ({ // "Waiting" is an overlay on active sessions (awaiting_input_since); // the server filters it before the limit so paging stays exact. status: filter === "waiting" ? "active" : filter || undefined, awaiting: filter === "waiting" || undefined, q: search || undefined, cwd: cwd || undefined, tool: tool || undefined, lane: lane ? Number(lane) : undefined, // A date input is a local calendar day: `to` covers that whole day. from: isoOrUndefined(from && `${from}T00:00:00`), to: isoOrUndefined(to && `${to}T23:59:59.999`), sort_by: sortBy, sort_desc: sortDesc, }), [filter, search, cwd, tool, lane, from, to, sortBy, sortDesc] ); // Server-side pagination: only the visible page is fetched. A newer request // (filter typed while a load is in flight) wins; older responses are dropped. const loadSeq = useRef(0); const load = useCallback(async () => { const seq = ++loadSeq.current; try { const res = await api.sessions.list({ ...query, limit: PAGE_SIZE, offset: page * PAGE_SIZE, }); if (seq !== loadSeq.current) return; setSessions(res.sessions); setTotal(res.total); setError(null); } catch (err) { if (seq === loadSeq.current) setError(err instanceof Error ? err.message : t("failedList")); } finally { if (seq === loadSeq.current) setLoading(false); } // `scope` is a dep so a data-scope change re-fetches; the api layer injects // the matching `sources` param. }, [query, page, scope, t]); useEffect(() => { load(); }, [load]); // Pull live Run handles so a row a dashboard pane holds can open that pane. const loadDashboardRuns = useCallback(() => { api.run .list() .then((r) => setRuns(r.items)) .catch(() => undefined); }, []); useEffect(() => { loadDashboardRuns(); }, [loadDashboardRuns]); useLiveRefresh(loadDashboardRuns, { intervalMs: 15000, // A pane starting/stopping re-broadcasts its lane (`lane_update`); the // server sends no run_status any more. The 15 s poll covers extra slots. match: (msg) => msg.type === "lane_update", }); useLiveRefresh(load, { match: (msg) => { if (msg.type === "session_created" || msg.type === "session_updated") return true; if (msg.type === "new_event") { const ev = msg.data as DashboardEvent; return ev.event_type === "Stop" || ev.event_type === "SessionEnd"; } // A remote source finished syncing: new remote sessions may have landed. return isRemoteDataRefreshMessage(msg); }, }); useEffect( () => eventBus.subscribe((msg) => { if (msg.type === "remote_source.status") loadSourceLabels(); }), [loadSourceLabels] ); async function handleExport(kind: "csv" | "json") { setExporting(true); try { const res = await api.sessions.list({ ...query, limit: 10000 }); const name = exportName("sessions", kind); if (kind === "json") downloadText(name, JSON.stringify(res.sessions, null, 2), "application/json"); else downloadText( name, toCsv(res.sessions, [ "id", "name", "status", "tool", "cwd", "model", "started_at", "ended_at", "agent_count", "cost", "lane_id", ]), "text/csv" ); // The server answers at most 10 000 rows: say the file is not all of them. if (res.total > res.sessions.length) toast.info(t("exportCapped", { shown: res.sessions.length, total: res.total })); } catch (err) { toast.error(err); } finally { setExporting(false); } } const hasFilters = !!(search || filter || cwd || tool || lane || from || to); // A `?page=` past the end (a stale link, rows deleted meanwhile) goes back to // the first page rather than showing an empty table. const pageCount = Math.ceil(total / PAGE_SIZE); useEffect(() => { if (!loading && pageCount > 0 && page >= pageCount) update({ page: null }); }, [loading, page, pageCount, update]); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); // The server already paginates, so the rendered page IS the loaded list. const paged = sessions; const filtered = sessions; // kept for empty-state checks below const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); return (
{rowCtx.menu && ( )}
{!embedded && (
)}
{/* Under History's tab bar the tab already names the page. */}

{t("title")}

{wsConnected ? ( {t("common:live")} ) : ( {t("common:offline")} )}

{t("sessionCount", { count: total })} {filter ? ` · ${FILTER_OPTIONS.find((o) => o.value === filter)?.label ?? filter}` : ""}

{/* Filters */}
{/* Search */}
setSearchInput(e.target.value)} className="input w-full pl-10" />
{/* Directory Selector */}
{/* Sort Controls */}
{/* Status Filters */}
{FILTER_OPTIONS.map((opt) => ( ))}
{/* Tool / workspace / date range */}
{hasFilters && ( )}
{error && (
{t("failedList")}: {error}
)} {!loading && !error && filtered.length === 0 ? ( ) : ( <>
{loading && paged.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( )) : null} {paged.map((session) => ( { if ((e.target as HTMLElement).closest("a,button")) return; navigate(`/sessions/${session.id}`); }} onContextMenu={(e) => rowCtx.open(e, session)} className="hover:bg-surface-4 transition-colors cursor-pointer group" > ))}
{t("tableSession")} {t("tableStatus")} {t("tableLastActive")} {t("tableDuration")} {t("tableAgents")} {t("tableCost")} {t("tableDirectory")}
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`} {session.source && session.source !== "local" && ( {sourceLabels.get(session.source) || session.source} )} {(() => { // A live dashboard pane holds it: open that pane. const run = runs.find((r) => r.sessionId === session.id); const href = run ? workspaceHref(session, runs) : null; return href ? ( {t("openInWorkspace")} ) : null; })()}

{session.id.slice(0, 12)}

{formatDateTime(session.last_activity || session.started_at)} {session.ended_at ? formatDuration(session.started_at, session.ended_at) : t("common:running")} {session.agent_count ?? "-"} {session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"} {session.cwd ? truncate(session.cwd, 30) : "-"}
{totalPages > 1 && (
{t("common:pagination.showing", { from: page * PAGE_SIZE + 1, to: Math.min((page + 1) * PAGE_SIZE, total), total, })}
{page + 1} / {totalPages}
)} )}
); }