import { useMemo, useState } from "react"; import { useSessions } from "../../hooks/useSessions"; import { fmtInt, fmtTime } from "../../lib/dashboard-format"; import { useTranslation } from "../../i18n"; import type { SessionRow, WorkspaceRow } from "../../types/sessions"; function fmtBytes(n: number): string { const v = Number(n) || 0; if (v < 1024) return `${v} B`; if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`; if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)} MB`; return `${(v / (1024 * 1024 * 1024)).toFixed(2)} GB`; } function parseTime(value: string | number | null | undefined): number { if (value == null || value === "") return 0; if (typeof value === "number") return value > 1e12 ? value : value * 1000; const ms = Date.parse(value); return Number.isFinite(ms) ? ms : 0; } function rowKey(row: SessionRow): string { return `${row.workspaceId}/${row.id}`; } type ConfirmState = | { mode: "one"; row: SessionRow } | { mode: "bulk"; rows: SessionRow[] } | { mode: "workspace"; workspace: WorkspaceRow } | null; type PreviewState = { loading: boolean; error: string | null; messages: { role: string; time: number | null; text: string }[]; truncated: boolean; row: SessionRow; } | null; export function SessionsPage() { const { t, lang } = useTranslation(); const locale = lang === "zh" ? "zh" : "en"; const { status, setStatus, workspace, setWorkspace, data, loading, error, setError, refresh, archiveSession, unarchiveSession, deleteSession, deleteWorkspace, getPreview, } = useSessions(); const [selected, setSelected] = useState>(new Set()); const [busyId, setBusyId] = useState(null); const [confirm, setConfirm] = useState(null); const [confirmBusy, setConfirmBusy] = useState(false); const [preview, setPreview] = useState(null); const workspaces = data?.workspaces ?? []; const sessions = data?.sessions ?? []; const workspaceMap = useMemo(() => { const m = new Map(); for (const w of workspaces) m.set(w.id, w); return m; }, [workspaces]); const allKeys = useMemo(() => sessions.map(rowKey), [sessions]); const allSelected = allKeys.length > 0 && allKeys.every((k) => selected.has(k)); const someSelected = allKeys.some((k) => selected.has(k)) && !allSelected; const toggleSelect = (row: SessionRow) => { const key = rowKey(row); setSelected((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const toggleSelectAll = () => { if (allSelected) setSelected(new Set()); else setSelected(new Set(allKeys)); }; const onArchive = async (row: SessionRow) => { const key = rowKey(row); setBusyId(key); try { await archiveSession(row.workspaceId, row.id); setSelected(new Set()); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusyId(null); } }; const onUnarchive = async (row: SessionRow) => { const key = rowKey(row); setBusyId(key); try { await unarchiveSession(row.workspaceId, row.id); setSelected(new Set()); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusyId(null); } }; const openPreview = async (row: SessionRow) => { setPreview({ loading: true, error: null, messages: [], truncated: false, row, }); try { const res = await getPreview(row.workspaceId, row.id, row.status); setPreview({ loading: false, error: null, messages: res.messages, truncated: res.truncated, row, }); } catch (e) { setPreview({ loading: false, error: e instanceof Error ? e.message : String(e), messages: [], truncated: false, row, }); } }; const bulkArchive = async () => { const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "active"); for (const row of rows) { try { await archiveSession(row.workspaceId, row.id); } catch (e) { setError(e instanceof Error ? e.message : String(e)); break; } } setSelected(new Set()); }; const bulkUnarchive = async () => { const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "archived"); for (const row of rows) { try { await unarchiveSession(row.workspaceId, row.id); } catch (e) { setError(e instanceof Error ? e.message : String(e)); break; } } setSelected(new Set()); }; const runConfirm = async () => { if (!confirm) return; setConfirmBusy(true); try { if (confirm.mode === "one") { await deleteSession(confirm.row.workspaceId, confirm.row.id, confirm.row.status); } else if (confirm.mode === "bulk") { for (const row of confirm.rows) { await deleteSession(row.workspaceId, row.id, row.status); } } else if (confirm.mode === "workspace") { await deleteWorkspace(confirm.workspace.id); if (workspace === confirm.workspace.id) setWorkspace("all"); } setConfirm(null); setSelected(new Set()); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setConfirmBusy(false); } }; const totalSessions = workspaces.reduce( (a, w) => a + (w.activeCount || 0) + (w.archivedCount || 0), 0 ); return (

{t("sessionsTitle")}

{t("sessionsSubtitle")}

{error && (
{error}
)}
{/* Workspace rail */}
{t("workspaces")}
{t("workspaceIsolatedHint")}
{workspaces.map((w) => { const total = (w.activeCount || 0) + (w.archivedCount || 0); const isEmpty = total === 0 || w.empty; return (
{isEmpty && ( )}
); })} {!workspaces.length && !loading && (
{t("noSessions")}
)}
{/* Sessions table */}
{( [ ["active", t("statusActive")], ["archived", t("statusArchived")], ["all", t("statusAll")], ] as const ).map(([value, label]) => ( ))}
{sessions.length > 0 && ( )} {selected.size > 0 && ( <> {t("selectedCount", { n: String(selected.size) })} {status !== "archived" && ( )} {status !== "active" && ( )} )}
{workspace === "all" ? t("allWorkspaces") : workspaceMap.get(workspace)?.name || workspace}
{t("sessionsInView", { n: String(sessions.length) })} {workspace !== "all" ? ` · ${t("isolatedToWorkspace")}` : ""}
{sessions.length === 0 ? ( ) : ( sessions.map((row) => { const key = rowKey(row); const ws = workspaceMap.get(row.workspaceId); const busy = busyId === key; return ( ); }) )}
{ if (el) el.indeterminate = someSelected; }} type="checkbox" className="h-4 w-4 accent-blue-600" checked={allSelected} disabled={!sessions.length} onChange={toggleSelectAll} aria-label={t("selectAll")} /> {t("sessionTitle")} {t("workspace")} {t("status")} {t("size")} {t("updated")} {t("actions")}
{loading ? t("scanning") : t("noSessions")}
toggleSelect(row)} />
{row.id} {row.workDir && ( {row.workDir} )}
{ws?.name || row.workspaceId} {row.status === "archived" ? ( {t("statusArchived")} ) : ( {t("statusActive")} )} {fmtBytes(row.bytes)} {fmtTime(parseTime(row.updatedAt), locale)}
{row.status === "active" ? ( ) : ( )}
{/* Attribution */}
{data?.home && <>{data.home}}
{/* Confirm dialog */} {confirm && (

{confirm.mode === "bulk" ? t("confirmBulkTitle") : confirm.mode === "workspace" ? t("confirmDeleteWorkspaceTitle") : t("confirmDeleteTitle")}

{confirm.mode === "bulk" ? t("confirmBulkDelete", { n: String(confirm.rows.length) }) : confirm.mode === "workspace" ? t("confirmDeleteWorkspace") : t("confirmDeleteSession")}

{confirm.mode === "one" && (
{confirm.row.title || confirm.row.id}
{confirm.row.id}
)} {confirm.mode === "bulk" && (
{t("selectedCount", { n: String(confirm.rows.length) })}
)} {confirm.mode === "workspace" && (
{confirm.workspace.name || confirm.workspace.id}
{confirm.workspace.root && (
{confirm.workspace.root}
)}
{confirm.workspace.id}
)}
)} {/* Preview dialog */} {preview && (

{t("previewTitle")}

{t("previewHint")}

{preview.row.title || preview.row.id}
{preview.row.id}
{preview.loading && (
{t("scanning")}
)} {preview.error && (
{preview.error}
)} {!preview.loading && !preview.error && !preview.messages.length && (
{t("previewEmpty")}
)} {preview.messages.map((m, i) => { const roleLabel = m.role === "user" ? t("roleUser") : m.role === "assistant" ? t("roleAssistant") : m.role === "system" ? t("roleSystem") : m.role; return ( ); })} {preview.truncated && (
{t("previewTruncated")}
)}
)}
); } // Per-message card with 500-char collapse + expand toggle. // Renders long texts lazily — prevents huge
 blocks from bloating the DOM.
const PREVIEW_TEXT_LIMIT = 500;

function PreviewMessageCard({
  role,
  roleLabel,
  time,
  text,
  locale,
}: {
  role: string;
  roleLabel: string;
  time: number | null;
  text: string;
  locale: string;
}) {
  const { t } = useTranslation();
  const [expanded, setExpanded] = useState(false);
  const isLong = text.length > PREVIEW_TEXT_LIMIT;
  const visible = expanded || !isLong ? text : text.slice(0, PREVIEW_TEXT_LIMIT);

  return (
    
{roleLabel} {time ? ( {fmtTime(time, locale)} ) : null}
        {visible}
        {!expanded && isLong && (
           …
        )}
      
{isLong && ( )}
); }