| 1 | import { Archive, ArrowLeft, MessageSquare, RotateCcw, Search, Trash2 } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import { app, onLegacyEmptySessionCleanupChanged, onProjectTreeChanged } from "../lib/bridge"; |
| 4 | import { getLocale, useT } from "../lib/i18n"; |
| 5 | import type { HistoryMessage, SessionMeta } from "../lib/types"; |
| 6 | import type { SessionRef } from "../lib/sessionRef"; |
| 7 | import type { LegacyEmptySessionCleanupStatus, SessionLifecycleRequest } from "../generated/desktopContract.generated"; |
| 8 | import { useConfirmDialog } from "./ConfirmDialog"; |
| 9 | import "./ArchivedSessionsList.css"; |
| 10 | |
| 11 | type TrashRow = { key: string; ref?: SessionRef; recoveryEntryId?: string; cleanupKind?: string; workspaceId: string; title: string; workspace: string; updatedAt: number; canRestore: boolean; canPreview: boolean; canPurge: boolean; health: string }; |
| 12 | export function ArchivedSessionsList({ active, onOpenSession }: { |
| 13 | active: boolean; onOpenSession: (ref: SessionRef) => Promise<void>; |
| 14 | legacyList?: () => Promise<SessionMeta[]>; legacyRestore?: (path: string) => Promise<void>; legacyPurge?: (path: string) => Promise<void>; |
| 15 | }) { |
| 16 | const t = useT(); |
| 17 | const [rows, setRows] = useState<TrashRow[]>([]); |
| 18 | const [query, setQuery] = useState(""); |
| 19 | const [loading, setLoading] = useState(true); |
| 20 | const [error, setError] = useState(""); |
| 21 | const [loadError, setLoadError] = useState(""); |
| 22 | const [notice, setNotice] = useState(""); |
| 23 | const [busy, setBusy] = useState(false); |
| 24 | const [selected, setSelected] = useState<TrashRow>(); |
| 25 | const [preview, setPreview] = useState<HistoryMessage[]>([]); |
| 26 | const [previewLoading, setPreviewLoading] = useState(false); |
| 27 | const [previewError, setPreviewError] = useState(""); |
| 28 | const [previewCursor, setPreviewCursor] = useState(""); |
| 29 | const [pendingRequest, setPendingRequest] = useState<SessionLifecycleRequest | null>(null); |
| 30 | const [cleanupStatus, setCleanupStatus] = useState<LegacyEmptySessionCleanupStatus>(); |
| 31 | const [cleanupRetrying, setCleanupRetrying] = useState(false); |
| 32 | const registryGeneration = useRef(0); |
| 33 | const surfaceGeneration = useRef(0); |
| 34 | const selectedKey = useRef(""); |
| 35 | const generation = useRef(0), previewGeneration = useRef(0), mutating = useRef(false); |
| 36 | const { confirm, dialog, dismiss } = useConfirmDialog(); |
| 37 | const reload = useCallback(async () => { |
| 38 | const seq = ++generation.current; |
| 39 | setLoading(true); |
| 40 | try { |
| 41 | const next: TrashRow[] = []; |
| 42 | let cursor = "", snapshotGeneration: number | undefined; |
| 43 | do { |
| 44 | const page = await app.ListTrashEntries("", cursor, 200); |
| 45 | if (seq !== generation.current) return; |
| 46 | if (snapshotGeneration !== undefined && snapshotGeneration !== page.generation) throw new Error(t("history.failedLoadHistory")); |
| 47 | snapshotGeneration = page.generation; |
| 48 | next.push(...page.items.map(row => ({ key: row.id, ref: row.ref ?? undefined, recoveryEntryId: row.recoveryEntryId, cleanupKind: row.cleanupKind, workspaceId: row.workspaceId, title: row.title, |
| 49 | workspace: row.workspaceTitle, updatedAt: row.archivedAt, canRestore: row.canRestore, |
| 50 | canPreview: row.canPreview, canPurge: row.canPurge, health: row.health }))); |
| 51 | const after = page.nextCursor ?? ""; |
| 52 | if (after && after === cursor) throw new Error(t("history.failedLoadHistory")); |
| 53 | cursor = after; |
| 54 | } while (cursor); |
| 55 | registryGeneration.current = snapshotGeneration ?? 0; |
| 56 | if (seq !== generation.current) return; |
| 57 | next.sort((a, b) => b.updatedAt - a.updatedAt || a.key.localeCompare(b.key)); |
| 58 | const selectedRow = next.find(row => row.key === selectedKey.current); |
| 59 | if (selectedKey.current && !selectedRow?.canPreview) { |
| 60 | selectedKey.current = ""; ++previewGeneration.current; |
| 61 | setSelected(undefined); setPreview([]); setPreviewCursor(""); setPreviewLoading(false); setPreviewError(""); |
| 62 | } else if (selectedRow) setSelected(selectedRow); |
| 63 | setRows(next); setLoadError(""); |
| 64 | try { |
| 65 | const cleanup = await app.GetLegacyEmptySessionCleanupStatus(); |
| 66 | if (seq === generation.current) setCleanupStatus(cleanup); |
| 67 | } catch { |
| 68 | // Trash remains usable when a future or damaged cleanup sidecar makes |
| 69 | // only the optional upgrade status unavailable. |
| 70 | if (seq === generation.current) setCleanupStatus(undefined); |
| 71 | } |
| 72 | } catch (err) { |
| 73 | if (seq === generation.current) setLoadError(err instanceof Error ? err.message : String(err)); |
| 74 | throw err; |
| 75 | } finally { if (seq === generation.current) setLoading(false); } |
| 76 | }, [t]); |
| 77 | useEffect(() => { |
| 78 | if (!active) return; |
| 79 | void reload().catch(() => {}); |
| 80 | const unsubscribe = onProjectTreeChanged(() => { if (!mutating.current) void reload().catch(() => {}); }); |
| 81 | const unsubscribeCleanup = onLegacyEmptySessionCleanupChanged(setCleanupStatus); |
| 82 | return () => { generation.current++; previewGeneration.current++; surfaceGeneration.current++; unsubscribe(); unsubscribeCleanup(); }; |
| 83 | }, [active, reload]); |
| 84 | useEffect(() => { if (!active) dismiss(); }, [active, dismiss]); |
| 85 | const select = async (row: TrashRow, cursor = "") => { |
| 86 | if (!row.ref) return; |
| 87 | const seq = ++previewGeneration.current; |
| 88 | selectedKey.current = row.key; |
| 89 | setSelected(row); setPreview([]); setPreviewLoading(true); setPreviewError(""); setPreviewCursor(""); |
| 90 | try { |
| 91 | const page = await app.ReadSessionHistory(row.ref, cursor, 32); |
| 92 | if (seq !== previewGeneration.current) return; |
| 93 | setPreview(page.messages); setPreviewCursor("nextCursor" in page ? String(page.nextCursor || "") : ""); |
| 94 | } catch (err) { if (seq === previewGeneration.current) setPreviewError(String(err)); } |
| 95 | finally { if (seq === previewGeneration.current) setPreviewLoading(false); } |
| 96 | }; |
| 97 | const closePreview = () => { selectedKey.current = ""; ++previewGeneration.current; setSelected(undefined); setPreview([]); setPreviewCursor(""); setPreviewLoading(false); setPreviewError(""); }; |
| 98 | const requestFor = (targets: TrashRow[], action: "restore" | "purge"): SessionLifecycleRequest => ({ |
| 99 | operationId: crypto.randomUUID(), action, expectedGeneration: registryGeneration.current, |
| 100 | targets: targets.map(row => row.ref |
| 101 | ? ({ ref: { ...row.ref } }) |
| 102 | : ({ workspaceId: row.workspaceId, recoveryEntryId: row.recoveryEntryId ?? "" })), |
| 103 | }); |
| 104 | const mutate = async (request: SessionLifecycleRequest) => { |
| 105 | if (mutating.current) return; |
| 106 | const surface = surfaceGeneration.current; |
| 107 | const kind = request.action; |
| 108 | mutating.current = true; setBusy(true); ++generation.current; setError(""); setNotice(""); setPendingRequest(request); |
| 109 | let succeeded = 0; |
| 110 | let retryable = false, failed = 0, conflicts = 0; |
| 111 | try { |
| 112 | const result = await app.ApplySessionLifecycle(request); |
| 113 | if (surface !== surfaceGeneration.current) return; |
| 114 | for (const item of result.items) { |
| 115 | if (!item.committed) { |
| 116 | failed++; |
| 117 | retryable ||= item.retryable; |
| 118 | if (item.errorCode === "state_conflict") conflicts++; |
| 119 | continue; |
| 120 | } |
| 121 | succeeded++; |
| 122 | const id = item.target.ref?.sessionId ?? item.target.recoveryEntryId?.replace(/^legacy-cleanup:/, ""); |
| 123 | setRows(current => current.filter(row => row.ref?.sessionId !== id && row.key !== id)); |
| 124 | if (selectedKey.current === id) closePreview(); |
| 125 | } |
| 126 | if (!retryable) setPendingRequest(null); |
| 127 | setNotice(t(kind === "restore" ? "history.restoreComplete" : "history.purgeComplete", { n: succeeded })); |
| 128 | try { await reload(); } catch { setNotice(t("history.operationRefreshFailed")); } |
| 129 | if (conflicts) setError(t("projectTree.sessionError.targetChanged")); |
| 130 | else if (failed) setError(t("history.trashPartialFailure", { n: failed })); |
| 131 | if (surface === surfaceGeneration.current && kind === "restore" && succeeded === 1 && request.targets.length === 1 && request.targets[0].ref) { |
| 132 | try { await onOpenSession(request.targets[0].ref); } catch { setNotice(t("history.restoredRefreshFailed")); } |
| 133 | } |
| 134 | } catch (err) { |
| 135 | if (surface !== surfaceGeneration.current) return; |
| 136 | const message = String(err); |
| 137 | if (message.includes("workspace mutation conflicts with persisted state")) { |
| 138 | setPendingRequest(null); |
| 139 | await reload().catch(() => {}); |
| 140 | setError(t("projectTree.sessionError.targetChanged")); |
| 141 | } else { |
| 142 | setError(message); |
| 143 | } |
| 144 | } finally { mutating.current = false; setBusy(false); } |
| 145 | }; |
| 146 | const purge = async (targets: TrashRow[]) => { |
| 147 | targets = targets.filter(row => row.canPurge); |
| 148 | if (!targets.length || mutating.current) return; |
| 149 | const request = requestFor(targets, "purge"), surface = surfaceGeneration.current; |
| 150 | if (await confirm({ title: t(targets.length > 1 ? "history.emptyTrashConfirm" : "history.purgeConfirm"), |
| 151 | message: targets.length > 1 ? t("history.emptyTrashExplanation", { n: targets.length }) : t("history.purgeExplanation", { name: targets[0].title }), |
| 152 | confirmLabel: t("history.permanentlyDelete"), cancelLabel: t("common.cancel"), tone: "danger" }) && surface === surfaceGeneration.current) await mutate(request); |
| 153 | }; |
| 154 | const refresh = () => { if (!pendingRequest) setError(""); void reload().catch(() => {}); }; |
| 155 | const retryCleanup = async () => { |
| 156 | if (cleanupRetrying) return; |
| 157 | setCleanupRetrying(true); setError(""); |
| 158 | try { |
| 159 | setCleanupStatus(await app.RetryLegacyEmptySessionCleanup()); |
| 160 | } catch (err) { |
| 161 | setError(err instanceof Error ? err.message : String(err)); |
| 162 | } finally { |
| 163 | setCleanupRetrying(false); |
| 164 | } |
| 165 | }; |
| 166 | const filtered = rows.filter(row => `${row.title}\n${row.workspace}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())); |
| 167 | return <div className="archived-sessions" aria-busy={busy}> |
| 168 | <div className="archived-sessions__toolbar"> |
| 169 | <label className="archived-sessions__search"><Search size={16} aria-hidden="true" /><input aria-label={t("history.searchPlaceholder")} placeholder={t("history.searchPlaceholder")} value={query} onChange={event => setQuery(event.target.value)} /></label> |
| 170 | <button className="btn btn--small" disabled={busy || loading} onClick={refresh}><RotateCcw size={14} />{t("history.refreshTrash")}</button> |
| 171 | <button className="btn btn--small btn--danger history-clear" disabled={busy || loading || !!error || !!loadError || !rows.some(row => row.canPurge)} onClick={() => void purge([...rows])}><Trash2 size={14} />{t("history.clearTrash")}</button> |
| 172 | </div> |
| 173 | {notice && <div className="management-notice" role="status">{notice}</div>} |
| 174 | {!!cleanupStatus?.pending && <div className="management-notice" role="status"> |
| 175 | {t("history.legacyCleanupPending", { n: cleanupStatus.pending })} |
| 176 | <button className="btn btn--small" disabled={cleanupRetrying} onClick={() => void retryCleanup()}>{t("history.recheckLegacyCleanup")}</button> |
| 177 | </div>} |
| 178 | {(error || pendingRequest) && <div className="management-notice" role="alert">{error}<button className="btn btn--small" disabled={busy} onClick={() => pendingRequest ? void mutate(pendingRequest) : refresh()}>{t(pendingRequest ? "history.retryFailed" : "common.retry")}</button></div>} |
| 179 | {loadError && <div className="management-notice" role="alert">{loadError}<button className="btn btn--small" disabled={busy} onClick={refresh}>{t("common.retry")}</button></div>} |
| 180 | <div className="archived-sessions__layout" data-detail={!!selected}> |
| 181 | <div className="archived-sessions__list"> |
| 182 | {loading && <p role="status">{t("common.loading")}</p>} |
| 183 | {!loading && !error && !filtered.length && <div className="archived-sessions__empty"><Archive size={30} /><h3>{t(query ? "history.noTrashMatches" : "history.noArchivedSessions")}</h3><p>{t(query ? "history.tryOtherSearch" : "history.emptyTrashHint")}</p></div>} |
| 184 | {filtered.map(row => <div className="archived-sessions__row" key={row.key} data-selected={selected?.key === row.key || undefined}> |
| 185 | <button className="archived-sessions__open" disabled={busy || !row.canPreview} onClick={() => void select(row)} title={row.title}><MessageSquare size={17} /><span><strong>{row.title}</strong><small>{row.workspace}{row.cleanupKind === "topic_placeholder" && <> · {t("history.legacyPlaceholder")}</>}{row.health === "purge_pending" && <> · {t("history.purgePending")}</>}{row.updatedAt > 0 && <> · {new Date(row.updatedAt).toLocaleDateString(getLocale())}</>}</small></span></button> |
| 186 | <button className="btn btn--small" disabled={busy || !row.canRestore} aria-label={t("history.restoreSession")} onClick={() => void mutate(requestFor([row], "restore"))}><RotateCcw size={14} />{t("history.restore")}</button> |
| 187 | <button className="btn btn--small archived-sessions__delete" disabled={busy || !row.canPurge} aria-label={`${t("history.permanentlyDelete")} ${row.title}`} onClick={() => void purge([row])}><Trash2 size={14} /></button> |
| 188 | </div>)} |
| 189 | </div> |
| 190 | {selected && <aside className="archived-sessions__preview" aria-label={t("history.previewRecovery")}> |
| 191 | <header><button className="btn btn--small" onClick={closePreview}><ArrowLeft size={14} />{t("history.backToTrash")}</button><h3>{selected.title}</h3><p>{t("history.previewReadOnly")}</p></header> |
| 192 | <div className="archived-sessions__messages"> |
| 193 | {previewLoading && <p role="status">{t("common.loading")}</p>} |
| 194 | {previewError && <div role="alert">{previewError}<button className="btn btn--small" onClick={() => void select(selected)}>{t("common.retry")}</button></div>} |
| 195 | {!previewLoading && !previewError && !preview.length && <p>{t("history.emptySession")}</p>} |
| 196 | {preview.map((message, index) => <article key={message.messageId || message.recordId || index}><small>{message.role}</small><div>{message.content}</div></article>)} |
| 197 | {previewCursor && <button className="btn btn--small" onClick={() => void select(selected, previewCursor)}>{t("projectTree.loadMore")}</button>} |
| 198 | </div> |
| 199 | </aside>} |
| 200 | </div> |
| 201 | {active && dialog} |
| 202 | </div>; |
| 203 | } |
| 204 |