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