返回 DeepSeek-Reasonix
SessionRecoveryVersionsHost.tsx
根目录 / desktop / frontend / src / components / SessionRecoveryVersionsHost.tsx
1 import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
2 import { app, onSessionRecoveryFailed } from "../lib/bridge";
3 import { useT } from "../lib/i18n";
4 import type { ProjectTopicKey } from "../lib/sessionCatalogTypes";
5 import { bindSessionVersionInspector } from "../lib/sessionRecoveryVersionHostBridge";
6 import { normalizeRecoveryLineageView, userVisibleRecoveryVersions } from "../lib/sessionRecoveryVersions";
7 import { useToast } from "../lib/toast";
8 import type { RecoveryLineageMember, RecoveryLineageView, SessionMeta } from "../lib/types";
9
10 const RecoveryLineageDialog = lazy(() => import("./RecoveryLineageDialog").then((module) => ({ default: module.RecoveryLineageDialog })));
11
12 type SessionVersionsState = { topic: ProjectTopicKey; view: RecoveryLineageView };
13
14 interface SessionRecoveryVersionsHostProps {
15 sessions?: SessionMeta[];
16 onResumeSession: (session: SessionMeta) => Promise<void>;
17 onRecoveryCreated: () => void;
18 onLineageChanged: () => void;
19 }
20
21 export function SessionRecoveryVersionsHost({ sessions, onResumeSession, onRecoveryCreated, onLineageChanged }: SessionRecoveryVersionsHostProps) {
22 const t = useT();
23 const { showToast } = useToast();
24 const [state, setState] = useState<SessionVersionsState | null>(null);
25
26 const showVersions = useCallback(async (topic: ProjectTopicKey, initial?: RecoveryLineageView) => {
27 try {
28 const view = normalizeRecoveryLineageView(initial ?? await app.GetRecoveryLineage(topic));
29 if (userVisibleRecoveryVersions(view).length > 1) setState({ topic, view });
30 } catch (error) {
31 showToast(error instanceof Error ? error.message : String(error), "error");
32 }
33 }, [showToast]);
34
35 const runtimeCallbacks = useRef({ onRecoveryCreated, showToast, showVersions, t });
36 useEffect(() => {
37 runtimeCallbacks.current = { onRecoveryCreated, showToast, showVersions, t };
38 }, [onRecoveryCreated, showToast, showVersions, t]);
39
40 useEffect(() => {
41 let stop: (() => void) | undefined;
42 let cancelled = false;
43 void import("../lib/sessionRecoveryRuntime").then(({ startSessionRecoveryRuntime }) => {
44 if (cancelled) return;
45 stop = startSessionRecoveryRuntime({
46 onRecovered: () => runtimeCallbacks.current.onRecoveryCreated(),
47 onDiverged: (topic, view) => {
48 const current = runtimeCallbacks.current;
49 current.showToast(current.t("recovery.divergedToast"), "warn", {
50 actionLabel: current.t("recovery.inspectLineage"),
51 onAction: () => { void current.showVersions(topic, view); },
52 });
53 },
54 });
55 });
56 return () => {
57 cancelled = true;
58 stop?.();
59 };
60 }, []);
61
62 useEffect(() => onSessionRecoveryFailed((event) => {
63 showToast(event.recoveryPending ? t("recovery.failedPending") : t("recovery.failed"), "error", event.recoveryPending && event.recoveryPath && event.topicId ? {
64 actionLabel: t("recovery.retry"),
65 onAction: () => { void app.RetrySessionRecovery({
66 scope: event.workspaceRoot ? "project" : "global", workspaceRoot: event.workspaceRoot,
67 topicId: event.topicId!, path: event.recoveryPath!,
68 }).catch((error) => showToast(error instanceof Error ? error.message : String(error), "error")); },
69 } : undefined);
70 }), [showToast, t]);
71
72 const inspectVersions = useCallback((session: SessionMeta, view: RecoveryLineageView) => {
73 if (!session.topicId) return;
74 void showVersions({
75 scope: session.scope || (session.workspaceRoot ? "project" : "global"),
76 workspaceRoot: session.workspaceRoot || undefined,
77 topicId: session.topicId,
78 path: session.path,
79 }, view);
80 }, [showVersions]);
81 useEffect(() => bindSessionVersionInspector(inspectVersions), [inspectVersions]);
82
83 const openVersion = useCallback(async (member: RecoveryLineageMember) => {
84 const topic = state?.topic;
85 if (!topic) return;
86 // A head lives inside the session's own log: selecting it moves the open
87 // tab in place, and resuming the path afterwards lands on it when closed.
88 if (member.headId && !member.selected) await app.ChooseRecoveryBranch({ ...topic, path: member.path, headId: member.headId });
89 const known = sessions?.find((session) => session.path === member.path);
90 await onResumeSession(known ?? sessionFromVersion(topic, member));
91 }, [onResumeSession, sessions, state?.topic]);
92
93 if (!state) return null;
94 return (
95 <Suspense fallback={null}>
96 <RecoveryLineageDialog
97 topic={state.topic}
98 initial={state.view}
99 onClose={() => setState(null)}
100 onChanged={(view) => {
101 setState((current) => current ? { ...current, view } : current);
102 onLineageChanged();
103 }}
104 onOpenVersion={openVersion}
105 />
106 </Suspense>
107 );
108 }
109
110 function sessionFromVersion(topic: ProjectTopicKey, member: RecoveryLineageMember): SessionMeta {
111 const activityAt = member.lastActivityAt || member.createdAt || 0;
112 return {
113 path: member.path, preview: member.preview || "", title: member.versionNote,
114 turns: member.turns, turnsState: "valid", createdAt: member.createdAt || 0,
115 lastActivityAt: activityAt, modTime: activityAt, current: false, open: member.open,
116 scope: topic.scope, workspaceRoot: topic.workspaceRoot, topicId: topic.topicId,
117 recovered: true, recoveryRole: member.role, recoveryCanonical: member.canonical,
118 };
119 }
120
120 lines Plain Text