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