返回 DeepSeek-Reasonix
useSessionUndo.ts
根目录 / desktop / frontend / src / app-runtime / useSessionUndo.ts
1 import { useState } from "react";
2 import { useCommittedCommand } from "../lib/useCommittedCommand";
3 import type { Item } from "../lib/useController";
4 import type { RewindUndoState } from "../lib/rewindTypes";
5 import type { RewindResultView } from "../lib/types";
6 import type { ForkTargetView } from "../lib/forkTargets";
7
8 export type SessionUndoInput = {
9 activeTabId: string | undefined;
10 activeTabReadOnly: boolean;
11 items: readonly Item[];
12 hydratePlaceholderActive: boolean;
13 controllerReady: boolean;
14 running: boolean;
15 messageActionOpen: boolean;
16 approvalOpen: boolean;
17 askOpen: boolean;
18 clearContextPending: boolean;
19 ports: {
20 rewindForTab(tabId: string, turn: number, scope: string): Promise<boolean>;
21 rewindForTabDetailed(tabId: string, turn: number, scope: string): Promise<RewindResultView>;
22 forkTurnForTab(tabId: string, target: ForkTargetView): Promise<boolean>;
23 refreshTabMetas(): void;
24 undoRewindForTab(tabId: string, transactionId: string): Promise<boolean>;
25 sendToTab(tabId: string, display: string, submit: string, original: string): Promise<void>;
26 composeInsert(tabId: string, text: string): void;
27 refreshDock(): void;
28 refreshProject(): void;
29 };
30 };
31
32 /**
33 * Owns the undo/rewind lifecycle: per-tab rewind state and committing flags,
34 * message-action rewinds (fork/code/summarize/full), edit-prompt rewinds and
35 * the committed-session revert handler. The undo banner still reads
36 * `rewindState`/`setRewindStateForTab` through this hook's return; only the
37 * banner identity and its DOM live in the footer region.
38 */
39 export function useSessionUndo(input: SessionUndoInput) {
40 const { activeTabId, items, ports } = input;
41 const [rewindStatesByTab, setRewindStatesByTab] = useState<Record<string, RewindUndoState>>({});
42 const [rewindCommittingByTab, setRewindCommittingByTab] = useState<Record<string, boolean>>({});
43 const [rewindSignal, setRewindSignal] = useState(0);
44
45 const setRewindStateForTab = useCommittedCommand((tabId: string, nextState: RewindUndoState | null) => {
46 if (!tabId) return;
47 setRewindStatesByTab(current => {
48 if (!nextState && !current[tabId]) return current;
49 const next = { ...current };
50 if (nextState) next[tabId] = nextState;
51 else delete next[tabId];
52 return next;
53 });
54 });
55
56 const setRewindCommittingForTab = useCommittedCommand((tabId: string, committing: boolean) => {
57 setRewindCommittingByTab((current) => {
58 const next = { ...current };
59 if (committing) next[tabId] = true;
60 else delete next[tabId];
61 return next;
62 });
63 });
64
65 const bumpRewindSignal = useCommittedCommand(() => setRewindSignal((value) => value + 1));
66
67 const handleSessionRevertCommitted = useCommittedCommand((sourceTabId: string, outcome: RewindResultView) => {
68 if (!sourceTabId || !outcome.ok) return;
69 setRewindStateForTab(sourceTabId, {
70 turnDiff: 0,
71 transactionId: outcome.transactionId,
72 undoAvailable: outcome.undoAvailable,
73 filesRestored: outcome.written ?? [],
74 filesRemoved: outcome.deleted ?? [],
75 });
76 ports.refreshDock();
77 ports.refreshProject();
78 });
79
80 const rewindState = activeTabId ? rewindStatesByTab[activeTabId] ?? null : null;
81 const rewindCommitting = Boolean(activeTabId && rewindCommittingByTab[activeTabId]);
82
83 /**
84 * Forks one persisted turn of the active tab into an independent child
85 * session. Unlike a rewind it never touches the source, so neither the rewind
86 * banner nor a read-only source blocks it: the child is written from the
87 * source, never into it.
88 */
89 const handleForkTurn = useCommittedCommand((target: ForkTargetView) => {
90 const sourceTabId = activeTabId;
91 if (!sourceTabId || !target?.turnId || !input.controllerReady || input.hydratePlaceholderActive) return;
92 void ports.forkTurnForTab(sourceTabId, target).then((ok) => {
93 if (!ok) return;
94 ports.refreshTabMetas();
95 ports.refreshProject();
96 });
97 });
98
99 const handleMessageAction = useCommittedCommand((turn: number, scope: string) => {
100 const sourceTabId = activeTabId;
101 if (!sourceTabId || input.activeTabReadOnly) return;
102 if (input.hydratePlaceholderActive) return;
103 if (scope === "fork") {
104 // Fork still goes through the controller (not optimistic).
105 ports.rewindForTab(sourceTabId, turn, scope).then((ok) => {
106 if (!ok) return;
107 ports.refreshTabMetas();
108 ports.refreshProject();
109 });
110 return;
111 }
112
113 // Code-only rewind only affects files — no message truncation,
114 // no optimistic UI needed. Execute immediately.
115 if (scope === "code") {
116 setRewindCommittingForTab(sourceTabId, true);
117 void ports.rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => {
118 setRewindCommittingForTab(sourceTabId, false);
119 if (!outcome.ok) return;
120 setRewindStateForTab(sourceTabId, {
121 turnDiff: 0,
122 transactionId: outcome.transactionId,
123 undoAvailable: outcome.undoAvailable,
124 filesRestored: outcome.written ?? [],
125 filesRemoved: outcome.deleted ?? [],
126 });
127 ports.refreshDock();
128 ports.refreshProject();
129 });
130 return;
131 }
132
133 // Summarize only compresses the conversation log — no files touched,
134 // no optimistic UI needed. Execute immediately like code-only rewind.
135 if (scope === "summ-from" || scope === "summ-upto") {
136 ports.rewindForTab(sourceTabId, turn, scope).then((ok) => {
137 if (!ok) return;
138 ports.refreshDock();
139 ports.refreshProject();
140 });
141 return;
142 }
143
144 const hasCheckpointTurns = items.some((it) => it.kind === "user" && it.checkpointTurn != null);
145 let boundaryIdx = -1;
146 let userCount = 0;
147 let targetUserCount = -1;
148 for (let i = 0; i < items.length; i++) {
149 if (items[i].kind === "user") {
150 const item = items[i] as Extract<Item, { kind: "user" }>;
151 const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn;
152 if (matches) {
153 boundaryIdx = i;
154 targetUserCount = userCount;
155 break;
156 }
157 userCount++;
158 }
159 }
160 if (boundaryIdx < 0) {
161 ports.rewindForTab(sourceTabId, turn, scope).then((ok) => {
162 if (!ok) return;
163 if (scope === "both") {
164 ports.refreshDock();
165 ports.refreshProject();
166 }
167 });
168 return;
169 }
170
171 const prevUserCount = items.filter((it) => it.kind === "user").length;
172 const turnDiff = prevUserCount - targetUserCount;
173 const userItem = items[boundaryIdx]?.kind === "user" ? items[boundaryIdx] as Extract<Item, { kind: "user" }> : undefined;
174 const prompt = userItem?.text ?? "";
175
176 // Immediate backend commit — only update UI after success.
177 setRewindCommittingForTab(sourceTabId, true);
178 void ports.rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => {
179 setRewindCommittingForTab(sourceTabId, false);
180 if (!outcome.ok) {
181 // Keep conversation/files as-is; notices already carry the reason.
182 return;
183 }
184 const targetTabId = outcome.tabId || sourceTabId;
185 setRewindStateForTab(targetTabId, {
186 turnDiff: outcome.tabId ? 0 : turnDiff,
187 transactionId: outcome.transactionId,
188 undoAvailable: outcome.undoAvailable,
189 undoTabId: sourceTabId,
190 filesRestored: outcome.written ?? [],
191 filesRemoved: outcome.deleted ?? [],
192 });
193 ports.composeInsert(targetTabId, prompt);
194 bumpRewindSignal();
195 if (scope === "both" || scope === "code") {
196 ports.refreshDock();
197 ports.refreshProject();
198 }
199 });
200 });
201
202 const handleUndoRewind = useCommittedCommand(() => {
203 const tabId = activeTabId;
204 const state = rewindState;
205 if (!tabId || !state) return;
206 const tx = state.transactionId;
207 const undoTabId = state.undoTabId || tabId;
208 const undo = tx && state.undoAvailable ? ports.undoRewindForTab(undoTabId, tx) : Promise.resolve(true);
209 void undo.then((ok) => {
210 if (!ok) return;
211 setRewindStateForTab(tabId, null);
212 ports.composeInsert(tabId, "");
213 bumpRewindSignal();
214 ports.refreshDock();
215 ports.refreshProject();
216 });
217 });
218
219 const handleEditPrompt = useCommittedCommand(async (turn: number, displayText: string, submitText?: string): Promise<boolean> => {
220 const sourceTabId = activeTabId;
221 if (!sourceTabId || input.activeTabReadOnly || !input.controllerReady || input.hydratePlaceholderActive
222 || rewindStatesByTab[sourceTabId] || input.running || input.messageActionOpen
223 || input.approvalOpen || input.askOpen || input.clearContextPending) return false;
224 const next = displayText.trim();
225 if (!next) return false;
226 const submit = (submitText ?? displayText).trim();
227 const hasCheckpointTurns = items.some((it) => it.kind === "user" && it.checkpointTurn != null);
228 let original = "";
229 let userCount = 0;
230 for (const item of items) {
231 if (item.kind !== "user") continue;
232 const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn;
233 if (matches) {
234 original = (item.submitText ?? item.text).trim();
235 break;
236 }
237 userCount++;
238 }
239 const outcome = await ports.rewindForTabDetailed(sourceTabId, turn, "conversation");
240 if (!outcome.ok) return false;
241 bumpRewindSignal();
242 const targetTabId = outcome.tabId || sourceTabId;
243 try {
244 await ports.sendToTab(targetTabId, next, submit, original);
245 return true;
246 } catch {
247 return false;
248 }
249 });
250
251 return {
252 rewindState,
253 rewindCommitting,
254 rewindSignal,
255 setRewindStateForTab,
256 setRewindCommittingForTab,
257 bumpRewindSignal,
258 handleSessionRevertCommitted,
259 handleMessageAction,
260 handleForkTurn,
261 handleUndoRewind,
262 handleEditPrompt,
263 };
264 }
265
265 lines TYPESCRIPT