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