import { useEffect, useMemo, useState } from "react"; import { useSessions } from "../../hooks/useSessions"; import { fmtInt, fmtTime } from "../../lib/dashboard-format"; import { useTranslation } from "../../i18n"; import type { BulkArchiveResult, 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 ArchiveAllMode = "month" | "half" | "week" | "custom"; type ArchiveAllState = { mode: ArchiveAllMode; /** YYYY-MM-DD, used when mode is "custom". */ date: string; busy: boolean; result: BulkArchiveResult | null; error: string | null; }; function localDateInput(d: Date): string { const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${d.getFullYear()}-${m}-${day}`; } /** Today minus one month — the default value of the custom-date picker. */ function defaultCustomDate(): string { const d = new Date(); d.setMonth(d.getMonth() - 1); return localDateInput(d); } /** Cutoff epoch ms for the selected option (custom dates start at local midnight). */ function cutoffFor(state: ArchiveAllState): number { const now = new Date(); if (state.mode === "month" || state.mode === "half" || state.mode === "week") { const d = new Date(now); if (state.mode === "month") d.setMonth(d.getMonth() - 1); if (state.mode === "half") d.setDate(d.getDate() - 15); if (state.mode === "week") d.setDate(d.getDate() - 7); return d.getTime(); } const [y, m, day] = state.date.split("-").map(Number); return new Date(y || now.getFullYear(), (m || 1) - 1, day || 1, 0, 0, 0, 0).getTime(); } 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, archiveBefore, 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 [archiveAll, setArchiveAll] = 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 ); useEffect(() => { if (!archiveAll) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setArchiveAll((s) => (s && s.busy ? s : null)); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [archiveAll]); const runArchiveAll = async () => { if (!archiveAll || archiveAll.busy) return; const cutoffMs = cutoffFor(archiveAll); setArchiveAll((s) => (s ? { ...s, busy: true, result: null, error: null } : s)); try { const res = await archiveBefore(cutoffMs); setArchiveAll((s) => (s ? { ...s, busy: false, result: res } : s)); setSelected(new Set()); } catch (e) { setArchiveAll((s) => s ? { ...s, busy: false, error: e instanceof Error ? e.message : String(e) } : s ); } }; 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" && ( )} )} {status !== "archived" && ( )}
{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}}
{/* Bulk archive dialog */} {archiveAll && (

{t("archiveAllTitle")}

{t("archiveAllDesc")}

{( [ ["month", t("archiveOptionMonth")], ["half", t("archiveOptionHalfMonth")], ["week", t("archiveOptionWeek")], ["custom", t("archiveOptionCustom")], ] as const ).map(([mode, label]) => ( ))}
{archiveAll.mode === "custom" && ( setArchiveAll((s) => (s ? { ...s, date: e.target.value } : s)) } className="bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" /> )}
{t("archiveAllConfirm", { date: new Date(cutoffFor(archiveAll)).toLocaleDateString( locale === "zh" ? "zh-CN" : "en-US" ), })}
{archiveAll.error && (
{t("archiveAllFailed")}: {archiveAll.error}
)} {archiveAll.result && archiveAll.result.archived === 0 && (
{t("archiveAllEmpty")}
)} {archiveAll.result && archiveAll.result.archived > 0 && (
{t("archiveAllDone", { archived: archiveAll.result.archived, skipped: archiveAll.result.skipped, })}
)} {archiveAll.result && archiveAll.result.errors.length > 0 && (
{archiveAll.result.errors.slice(0, 3).map((err, i) => (
{err}
))}
)}
)} {/* 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 && ( )}
); }