返回 DeepSeek-Reasonix
HistoricalRecoveryList.tsx
根目录 / desktop / frontend / src / components / HistoricalRecoveryList.tsx
1 import { ErrorMessage } from "./ErrorMessage";
2 import { useCallback, useEffect, useRef, useState } from "react";
3 import { app, onProjectTreeChanged } from "../lib/bridge";
4 import { asArray } from "../lib/array";
5 import { useT } from "../lib/i18n";
6 import type { RecoveryEntryView, SessionRestoreResult } from "../generated/desktopContract.generated";
7 import type { HistoryMessage } from "../lib/types";
8 import type { SessionRef } from "../lib/sessionRef";
9 import { useManagementT } from "../lib/managementLocale";
10
11 export function HistoricalRecoveryList({ active, onOpenSession }: {
12 active: boolean;
13 onOpenSession?: (ref: SessionRef) => Promise<void>;
14 }) {
15 const t = useT();
16 const m = useManagementT();
17 const [items, setItems] = useState<RecoveryEntryView[]>([]);
18 const [query, setQuery] = useState("");
19 const [nextCursor, setNextCursor] = useState("");
20 const [error, setError] = useState("");
21 const [loading, setLoading] = useState(false);
22 const [busy, setBusy] = useState(false);
23 const [preview, setPreview] = useState<HistoryMessage[]>([]);
24 const [restored, setRestored] = useState<SessionRestoreResult>();
25 const [workspaces, setWorkspaces] = useState<Record<string,string>>({});
26 const registryGeneration = useRef(0);
27 const surfaceGeneration = useRef(0);
28 const pendingRequests = useRef(new Map<string, import("../generated/desktopContract.generated").SessionLifecycleRequest>());
29 const generation = useRef(0);
30 const previewGeneration = useRef(0);
31 const mutating = useRef(false);
32 const reload = useCallback(async (cursor = "") => {
33 const seq = ++generation.current;
34 setLoading(true);
35 try {
36 const page = await app.ListRecoveryEntries(query, cursor, 50);
37 if (seq !== generation.current) return;
38 const rows = asArray<RecoveryEntryView>(page.items);
39 registryGeneration.current = page.generation;
40 setNextCursor(page.nextCursor ?? "");
41 setItems(current => cursor ? [...current, ...rows.filter(row => !current.some(old => old.id === row.id))] : rows);
42 setError("");
43 } catch (err) {
44 if (seq === generation.current) setError(err instanceof Error ? err.message : String(err));
45 throw err;
46 } finally {
47 if (seq === generation.current) setLoading(false);
48 }
49 }, [query]);
50 useEffect(() => {
51 if (!active) return;
52 void reload().catch(() => {});
53 const unsubscribe = onProjectTreeChanged(() => { if (!mutating.current) void reload().catch(() => {}); });
54 return () => { generation.current++; previewGeneration.current++; surfaceGeneration.current++; unsubscribe(); };
55 }, [active, reload]);
56 const restore = async (entry: RecoveryEntryView) => {
57 if (mutating.current) return;
58 const surface = surfaceGeneration.current;
59 mutating.current = true; setBusy(true);
60 const seq = ++generation.current;
61 const key = `${entry.id}:${workspaces[entry.id] ?? ""}`;
62 try {
63 const request = pendingRequests.current.get(key) ?? { operationId: crypto.randomUUID(), action: "restore",
64 targets: [{ recoveryEntryId: entry.id, workspaceId: workspaces[entry.id] }], expectedGeneration: registryGeneration.current };
65 pendingRequests.current.set(key, request);
66 const response = await app.ApplySessionLifecycle(request);
67 const item = response.items[0];
68 if (item && !item.committed && item.retryable === false) pendingRequests.current.delete(key);
69 if (!item?.committed || !item.ref) throw new Error(t("history.failedLoadHistory"));
70 const result: SessionRestoreResult = { session: item.ref, workspaceId: item.workspaceId, generation: response.generation };
71 if (seq !== generation.current) return;
72 setRestored(result);
73 setItems(current => current.filter(row => row.id !== entry.id));
74 try { await reload(); } catch { setError(t("history.restoredRefreshFailed")); }
75 if (surface === surfaceGeneration.current && onOpenSession) {
76 try { await onOpenSession(result.session); } catch { setError(t("history.restoredRefreshFailed")); }
77 }
78 } catch (err) {
79 if (String(err).includes("workspace mutation conflicts with persisted state")) {
80 pendingRequests.current.delete(key);
81 await reload().catch(() => {});
82 setError(String(err));
83 return;
84 }
85 if (seq === generation.current) setError(err instanceof Error ? err.message : String(err));
86 } finally { mutating.current = false; setBusy(false); }
87 };
88 const showPreview = async (entry: RecoveryEntryView) => {
89 const seq = ++previewGeneration.current;
90 try {
91 const page = await app.PreviewRecoveryEntry(entry.id);
92 if (seq === previewGeneration.current) setPreview(asArray<HistoryMessage>(page.messages));
93 } catch (err) {
94 if (seq === previewGeneration.current) setError(err instanceof Error ? err.message : String(err));
95 }
96 };
97 return <div className="archived-sessions">
98 <p>{m("historicalDescription")}</p>
99 <input aria-label={t("history.searchPlaceholder")} placeholder={t("history.searchPlaceholder")} value={query} disabled={busy} onChange={event => setQuery(event.target.value)} />
100 <button className="btn btn--small" disabled={busy || loading} onClick={() => void reload().catch(() => {})}>{t("common.retry")}</button>
101 {loading && <div role="status">{t("common.loading")}</div>}
102 {error && <div role="alert"><ErrorMessage error={error} /></div>}
103 {restored && onOpenSession && <button className="btn btn--small" onClick={() => void onOpenSession(restored.session).catch(err => setError(String(err)))}>{t("history.openRestored")}</button>}
104 {!loading && !error && items.length === 0 && <p>{m("noHistoricalSessions")}</p>}
105 {items.map(entry => <div className="archived-sessions__row" key={entry.id}>
106 <span>{entry.title}</span>
107 <small>{entry.format} · {entry.status}</small>
108 {!entry.canRestore && <span>{t("history.recoveryReview")}</span>}
109 {entry.reason === "workspace_conflict" && <select aria-label={t("history.recoveryWorkspace")} value={workspaces[entry.id] ?? ""} disabled={busy}
110 onChange={event => setWorkspaces(current => ({ ...current, [entry.id]: event.target.value }))}>
111 <option value="">{t("history.recoveryWorkspace")}</option>
112 {(entry.workspaceChoices ?? []).map(workspace => <option key={workspace.id} value={workspace.id}>{workspace.title}</option>)}
113 </select>}
114 <button className="btn btn--small" disabled={busy || !entry.canPreview} onClick={() => void showPreview(entry)}>{t("history.previewRecovery")}</button>
115 <button className="btn btn--small" disabled={busy || !entry.canRestore || (entry.reason === "workspace_conflict" && !workspaces[entry.id])} onClick={() => void restore(entry)}>{t("history.restore")}</button>
116 </div>)}
117 {nextCursor && <button className="btn btn--small" disabled={busy || loading} onClick={() => void reload(nextCursor).catch(() => {})}>{t("projectTree.loadMore")}</button>}
118 {preview.length > 0 && <div aria-label={t("history.previewRecovery")}>{preview.map((message, index) => <pre key={message.messageId || message.recordId || index}>{message.content}</pre>)}</div>}
119 </div>;
120 }
121
121 lines Plain Text