返回 DeepSeek-Reasonix
ArchivedSessionsList.tsx
根目录 / desktop / frontend / src / components / ArchivedSessionsList.tsx
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
218 lines Plain Text