| 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 |