/** * @file Sessions.tsx * @description Displays a list of all recorded sessions with filtering, searching, and pagination features. 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, useState, useCallback, useSyncExternalStore } from "react"; import { Link, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { FolderOpen, Search, ChevronRight, RefreshCw, SortDesc, SortAsc, ChevronDown, Play, Server, } from "lucide-react"; import { api } from "../lib/api"; import type { RemoteSource } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useDataScope } from "../lib/dataScope"; import { SessionStatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; import { TableRowSkeleton } from "../components/Skeleton"; import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format"; import { effectiveSessionStatus, isSessionAwaitingInput, sessionAwaitingReason, } from "../lib/types"; import type { Session, DashboardEvent } from "../lib/types"; const PAGE_SIZE = 10; export function Sessions() { const navigate = useNavigate(); const { t } = useTranslation("sessions"); const [sessions, setSessions] = useState([]); const [total, setTotal] = useState(0); const [filter, setFilter] = useState(""); // `searchInput` is what the user types; `search` is the debounced value // actually sent to the server. Without debouncing, every keystroke would // hit /api/sessions. const [searchInput, setSearchInput] = useState(""); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(true); const [page, setPage] = useState(0); const [cwd, setCwd] = useState(""); const [sortBy, setSortBy] = useState("time"); const [sortDesc, setSortDesc] = useState(true); 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()); // Set of session IDs that are currently being driven by an in-flight Run // handle on /run. Lets us badge those rows with a "Run" link. const [dashboardRunIds, setDashboardRunIds] = useState>(new Set()); 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(() => setSearch(searchInput.trim()), 300); return () => window.clearTimeout(id); }, [searchInput]); 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]); // Server-side pagination: only the visible page is fetched. Cost // computation on the server scales with PAGE_SIZE, not with the total // session count, so this stays cheap regardless of how many sessions // exist in the database. const load = useCallback(async () => { try { // The "waiting" filter is a UI-only overlay derived from the // awaiting_input_since column - the underlying SessionStatus is // still "active". Map it to a client-side filter on top of the // active set so paging/totals stay consistent with the visible rows. if (filter === "waiting") { const res = await api.sessions.list({ status: "active", q: search || undefined, cwd: cwd || undefined, sort_by: sortBy, sort_desc: sortDesc, limit: 10000, offset: 0, }); const waiting = res.sessions.filter(isSessionAwaitingInput); setTotal(waiting.length); setSessions(waiting.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); return; } const params: { status?: string; q?: string; cwd?: string; sort_by?: string; sort_desc?: boolean; limit: number; offset: number; } = { limit: PAGE_SIZE, offset: page * PAGE_SIZE, sort_by: sortBy, sort_desc: sortDesc, }; if (filter) params.status = filter; if (search) params.q = search; if (cwd) params.cwd = cwd; const res = await api.sessions.list(params); setSessions(res.sessions); setTotal(res.total); } finally { setLoading(false); } // `scope` is a dep so a data-scope change re-fetches; the api layer injects // the matching `sources` param. }, [filter, search, cwd, sortBy, sortDesc, page, scope]); useEffect(() => { load(); }, [load]); // Reset to page 0 whenever filters or sort changes. useEffect(() => { setPage(0); }, [filter, search, cwd, sortBy, sortDesc]); useEffect(() => { return eventBus.subscribe((msg) => { if (msg.type === "session_created" || msg.type === "session_updated") { load(); } if (msg.type === "new_event") { const ev = msg.data as DashboardEvent; if (ev.event_type === "Stop" || ev.event_type === "SessionEnd") { load(); } } if (msg.type === "run_status") { loadDashboardRuns(); } // A remote source finished syncing: new remote sessions may have landed. if (msg.type === "remote_source.status") { loadSourceLabels(); if (isRemoteDataRefreshMessage(msg)) load(); } else if (isRemoteDataRefreshMessage(msg)) { load(); } }); // loadDashboardRuns is a stable useCallback declared below; referenced at // event time only (not in deps) to avoid a temporal-dead-zone at render. }, [load, loadSourceLabels]); // Pull active Run handles so we can mark which sessions are being driven // from /run right now. Refresh on mount, on run_status WS messages, and // every 15s as a safety net for stale browser state. const loadDashboardRuns = useCallback(() => { api.run .list() .then((r) => { const ids = new Set(); for (const h of r.items) { if (h.sessionId) ids.add(h.sessionId); } setDashboardRunIds(ids); }) .catch(() => undefined); }, []); useEffect(() => { loadDashboardRuns(); const t = setInterval(loadDashboardRuns, 15000); return () => clearInterval(t); }, [loadDashboardRuns]); 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 (

{t("title")}

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

{t("sessionCount", { count: total })} {filter ? ` ${filter}` : ""}

{/* Filters */}
{/* Search */}
setSearchInput(e.target.value)} className="input w-full pl-10" />
{/* Directory Selector */}
{/* Sort Controls */}
{/* Status Filters */}
{FILTER_OPTIONS.map((opt) => ( ))}
{!loading && filtered.length === 0 ? ( ) : ( <>
{loading && paged.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( )) : null} {paged.map((session) => ( navigate(`/sessions/${session.id}`)} 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} )} {dashboardRunIds.has(session.id) && ( e.stopPropagation()} className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors" title={t("dashboardRunBadge", "Driven by Run page · click to open")} > {t("common:dashboardRun", "Run")} )}

{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}
)} )}
); }