From 5b9035f25e1f5faf86aa1e1f0e5f66bc596447b2 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 25 Sep 2026 20:56:47 +0200 Subject: [PATCH 1/4] fix(timeline): keep queued zoom levels through span and focus saves A pill resize and a focus commit saved whole documents outside the zoom chain, built before a level still being saved: whichever landed last put the other's field back. Both now queue on the chain and read the document inside it, with the pane writes' project, epoch and unknown-save guards. Queueing alone would not save the focus: the level landing first also replaces the dragged document on screen. The commit keeps the drag's last value and puts it back on top of such a write, one undo step. The pre-drag base and the rollback on failure are unchanged otherwise. --- src/lib/ai-edition/store/useTimeline.test.ts | 57 ++++ src/lib/ai-edition/store/useTimeline.ts | 266 +++++++++++-------- 2 files changed, 216 insertions(+), 107 deletions(-) diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 3848b5ff1..cd510dc83 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -1296,6 +1296,63 @@ describe("useTimeline undo history", () => { }); }); + // A resize built its save from the render's document, outside the zoom chain: whichever of + // the two saves landed last put the other's field back. + it("keeps a pending zoom level when the pill is resized before it lands", async () => { + seed(docWithZoom); + const gate = gateFirstSave(); + const { result } = renderTimeline(); + + const pDepth = result.current.updateZoomDepth("zoom_a", 4); + const pSpan = result.current.updateZoomSpan("zoom_a", 1500, 3500); + await waitFor(() => expect(gate.release).toEqual(expect.any(Function))); + await act(async () => { + gate.release?.(); + await Promise.all([pDepth, pSpan]); + }); + + expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ + depth: 4, + startMs: 1500, + endMs: 3500, + }); + }); + + // The inspector's "Reset focus point" is a live write and its commit in one click, next to + // the level buttons. The level landing first also takes the live focus off screen, so the + // commit has to put it back on top rather than save what it finds. + it("keeps a pending zoom level when a focus is committed before it lands", async () => { + seed(docWithZoom); + const gate = gateFirstSave(); + const { result } = renderTimeline(); + + const pDepth = result.current.updateZoomDepth("zoom_a", 4); + await waitFor(() => expect(gate.release).toEqual(expect.any(Function))); + act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 })); + const pCommit = result.current.commitZoomFocus(); + await act(async () => { + gate.release?.(); + await Promise.all([pDepth, pCommit]); + }); + + expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ + depth: 4, + focus: { cx: 0.8, cy: 0.2 }, + }); + // One undo step each, the focus first. + act(() => { + expect(undo()).toBe(true); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ + depth: 4, + focus: { cx: 0.5, cy: 0.5 }, + }); + act(() => { + expect(undo()).toBe(true); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(3); + }); + // Rebase-review finding (queued zoom writes vs. document replacement): a zoom write // queued behind a still-pending one starts AFTER an undo has restored the document, // and must not apply its stale patch to the replacement. The in-flight write itself diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index d47d1a4a4..259d15edc 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -135,6 +135,8 @@ export function useTimeline() { // was never written. const zoomFocusRollbackRef = useRef(null); const zoomFocusLiveRef = useRef(null); + // And the focus drag's last value, which its commit puts back if a zoom write lands over it. + const zoomFocusEditRef = useRef<{ id: string; focus: { cx: number; cy: number } } | null>(null); const annotationRollbackRef = useRef(null); const annotationLiveRef = useRef(null); @@ -148,6 +150,7 @@ export function useTimeline() { useEffect(() => { zoomFocusRollbackRef.current = null; zoomFocusLiveRef.current = null; + zoomFocusEditRef.current = null; annotationRollbackRef.current = null; annotationLiveRef.current = null; }, [projectId]); @@ -624,29 +627,112 @@ export function useTimeline() { [saveDocument], ); + // The zoom pane's own write chain -- see `queueZoomWrite`. Only `enqueue` is used, so + // there is no fallback document to hand it. + const { enqueue: enqueueZoomWrite } = useSequentialTimelineOps({ + fallbackDocument: null, + saveDocument, + }); + + // Every whole-document write to one zoom goes through this chain: the pane's one-field + // writes (level, 3D tilt, focus mode, cursor, click impact), a pill's span, and the focus + // commit. `write` runs INSIDE the chain, on the document the previous zoom write left, and + // returns its `saveDocument`. The level buttons step while the previous save is still out, + // and 3 -> 4 -> 5 built both saves from the render's depth-3 document: the main process does + // not order them, so the 4 could land last, and even in order one Ctrl+Z skipped a level. A + // neighbouring select changed, a pill resized or a focus committed while a level was pending + // rebuilt from that same document and put 3 back. Resolves `saveDocument`'s answer, so the + // level buttons can retry a failed write, or `"timeout"` when it did not come in time. + // + // Each request is bound to the project and write epoch it was asked against — the same + // pair `addAsset` samples: an undo bumps the epoch, a project switch swaps both, and a + // queued patch that only STARTS after such a replacement must not apply to the document + // that replaced its target. A save whose answer is unknown (`saveWithDeadline` timed out + // with the bridge still silent) may still land, so later zoom writes are refused until it + // settles instead of racing it — the same "a queued write racing a stuck one" the + // `waitForDocumentSaves` header calls out. The block is keyed to the save's own epoch: + // once a replacement moves the epoch, that save can no longer install anything + // (`saveDocument` drops it) and must stop blocking; a project switch does not move the + // epoch, so there the stuck save can still land and the block correctly stays. + const unknownZoomSavesRef = useRef>([]); + const queueZoomWrite = useCallback( + (write: (doc: AxcutDocument) => Promise) => { + const epoch = currentWriteEpoch(); + const projectId = useProjectStore.getState().projectId; + return enqueueZoomWrite(async () => { + if (useProjectStore.getState().projectId !== projectId || currentWriteEpoch() !== epoch) { + return false; + } + if (unknownZoomSavesRef.current.filter((stuck) => stuck === epoch).length > 0) { + return false; + } + const doc = useProjectStore.getState().document; + if (!doc) return false; + const save = write(doc); + const outcome = await saveWithDeadline(save); + if (outcome === "timeout") { + // Unknown, not failed: the write may still land. Refuse later writes into + // this same document generation until the save settles or the epoch moves + // past it. + unknownZoomSavesRef.current.push(epoch); + void save + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + unknownZoomSavesRef.current = unknownZoomSavesRef.current.filter( + (stuck) => stuck !== epoch, + ); + }); + } + return outcome; + }); + }, + [enqueueZoomWrite], + ); + + // Reports not-taken for an unknown answer too, so the buttons retry. + const saveZoomPatch = useCallback( + (id: string, patch: Partial) => + queueZoomWrite((doc) => + saveDocument( + { + ...doc, + zoomRanges: patchPillById(doc.zoomRanges, id, patch) as AxcutDocument["zoomRanges"], + }, + { history: true }, + ), + ).then((outcome) => outcome === true), + [queueZoomWrite, saveDocument], + ); + // Span edits are GROUP-AWARE: dragging/resizing a pill re-anchors every fragment // under the pill to the new ruler span, so an edit that crosses a clip // boundary re-splits and one dragged back inside a clip collapses — one user edit stays // one pill. See timelineMap.reanchorGroupSpan. const updateZoomSpan = useCallback( - async (id: string, startMs: number, endMs: number) => { - if (!document) return; + (id: string, startMs: number, endMs: number) => { const s = finiteMs(startMs); const e = finiteMs(endMs); - const next: AxcutDocument = { - ...document, - zoomRanges: replacePillSpan( - document.zoomRanges, - id, - Math.min(s, e), - Math.max(s, e), - document.timeline.clips, - () => createId("zoom"), - ) as AxcutDocument["zoomRanges"], - }; - await saveDocument(next, { history: true }); + return queueZoomWrite((doc) => + saveDocument( + { + ...doc, + zoomRanges: replacePillSpan( + doc.zoomRanges, + id, + Math.min(s, e), + Math.max(s, e), + doc.timeline.clips, + () => createId("zoom"), + ) as AxcutDocument["zoomRanges"], + }, + { history: true }, + ), + ); }, - [document, saveDocument], + [queueZoomWrite, saveDocument], ); // ponytail: the focus overlay drags at pointermove frequency (~60-120 Hz). @@ -666,115 +752,81 @@ export function useTimeline() { // behind when the commit failed and rolled the document back to that very // document — a Ctrl+Z that visibly did nothing, with `future` already wiped. if (zoomFocusLiveRef.current !== doc) zoomFocusRollbackRef.current = doc; + const edit = { id, focus: { cx: finiteFraction(focus.cx), cy: finiteFraction(focus.cy) } }; const next: AxcutDocument = { ...doc, zoomRanges: patchPillById(doc.zoomRanges, id, { - focus: { cx: finiteFraction(focus.cx), cy: finiteFraction(focus.cy) }, + focus: edit.focus, }) as AxcutDocument["zoomRanges"], }; setDocument(next, { history: false }); zoomFocusLiveRef.current = next; + zoomFocusEditRef.current = edit; }, [setDocument], ); + // On the zoom chain, like every zoom write: a level still being saved when the focus is + // committed would otherwise land first and be overwritten by this save, built before it. const commitZoomFocus = useCallback(async () => { - const doc = useProjectStore.getState().document; - if (!doc) return; - // The snapshot counts only while the document on screen is still the one this - // hook's last live write produced -- `updateZoomFocusLive`'s own identity test, - // read the other way round. Without it a commit that arrives with no live write - // in front of it picks up whatever an abandoned drag left behind, and every - // recording write since has already put the states in between on the stack: as a - // `historyBase` that makes one Ctrl+Z step over the lot, and on the failure path - // below it puts that buried document back on screen, silently dropping them. - // `handlePointerDown` sets `draggingRef` BEFORE its live write and that write - // returns early on a zero-size overlay rect, so `endDrag` can reach here bare. - const rollback = zoomFocusLiveRef.current === doc ? zoomFocusRollbackRef.current : null; + // What the drag left, taken now. While the commit waits its turn, a zoom write queued + // before the drag can land, and it installs ITS document, built before the drag, in + // place of the dragged one. + const live = zoomFocusLiveRef.current; + const rollback = zoomFocusRollbackRef.current; + const edit = zoomFocusEditRef.current; zoomFocusRollbackRef.current = null; zoomFocusLiveRef.current = null; - // `historyBase: rollback` — the pre-drag document, not the one the store holds - // (that is the dragged one, written live). `null` when no live write happened, - // which records nothing, which is right: nothing changed. - if (!(await saveDocument(doc, { history: true, historyBase: rollback })) && rollback) { + zoomFocusEditRef.current = null; + const outcome = await queueZoomWrite((doc) => { + // The snapshot counts only while the document on screen is still the one this + // hook's last live write produced -- `updateZoomFocusLive`'s own identity test, + // read the other way round. Without it a commit that arrives with no live write + // in front of it picks up whatever an abandoned drag left behind, and every + // recording write since has already put the states in between on the stack: as a + // `historyBase` that makes one Ctrl+Z step over the lot, and on the failure path + // below it puts that buried document back on screen, silently dropping them. + // `handlePointerDown` sets `draggingRef` BEFORE its live write and that write + // returns early on a zero-size overlay rect, so `endDrag` can reach here bare. + // + // `historyBase` is the pre-drag document, not the one the store holds (that is the + // dragged one, written live); `null` when no live write happened, which records + // nothing, which is right: nothing changed. + let next = doc; + let historyBase = doc === live ? rollback : null; + const pill = edit ? doc.zoomRanges.find((zoom) => zoom.id === edit.id) : undefined; + if ( + doc !== live && + edit && + pill && + (pill.focus.cx !== edit.focus.cx || pill.focus.cy !== edit.focus.cy) + ) { + // A zoom write landed over the drag and took its focus off screen. The focus goes + // back on top of that write, so both stay, and one Ctrl+Z takes the focus off it + // again. When the writes since carried the focus along, there is nothing to add. + next = { + ...doc, + zoomRanges: patchPillById(doc.zoomRanges, edit.id, { + focus: edit.focus, + }) as AxcutDocument["zoomRanges"], + }; + historyBase = doc; + } + return saveDocument(next, { history: true, historyBase }); + }); + if (outcome === false && rollback) { useProjectStore.setState((state) => - // `dirty` is deliberately NOT cleared. The rollback target is the last document - // this drag started from, which is not the same as the last SAVED one: with two - // commits in flight the first one's unsaved document is what we restore. Saying - // "clean" there tells `beforeunload` and `setHasUnsavedChanges` there is nothing - // to save, and the window closes on real work without prompting. - state.document === doc ? { document: rollback, revision: state.revision + 1 } : {}, + // Only while the dragged document is still the one on screen: anything else + // there was put by a write that landed. `dirty` is deliberately NOT cleared. The + // rollback target is the last document this drag started from, which is not the + // same as the last SAVED one: with two commits in flight the first one's unsaved + // document is what we restore. Saying "clean" there tells `beforeunload` and + // `setHasUnsavedChanges` there is nothing to save, and the window closes on real + // work without prompting. + state.document === live ? { document: rollback, revision: state.revision + 1 } : {}, ); } - }, [saveDocument]); - - // The zoom pane's own write chain -- see `saveZoomPatch`. Only `enqueue` is used, so - // there is no fallback document to hand it. - const { enqueue: enqueueZoomWrite } = useSequentialTimelineOps({ - fallbackDocument: null, - saveDocument, - }); - - // The zoom pane's one-field writes: level, 3D tilt, focus mode, cursor, click impact. Each - // is a whole-document save, so they share one chain and read the document INSIDE it. The - // level buttons step while the previous save is still out, and 3 -> 4 -> 5 built both saves - // from the render's depth-3 document: the main process does not order them, so the 4 could - // land last, and even in order one Ctrl+Z skipped a level. A neighbouring select changed - // while a level was pending rebuilt from that same document and put 3 back. Resolves - // `saveDocument`'s answer, so the level buttons can retry a failed write. - // - // Each request is bound to the project and write epoch it was asked against — the same - // pair `addAsset` samples: an undo bumps the epoch, a project switch swaps both, and a - // queued patch that only STARTS after such a replacement must not apply to the document - // that replaced its target. A save whose answer is unknown (`saveWithDeadline` timed out - // with the bridge still silent) may still land, so later zoom writes are refused until it - // settles instead of racing it — the same "a queued write racing a stuck one" the - // `waitForDocumentSaves` header calls out. The block is keyed to the save's own epoch: - // once a replacement moves the epoch, that save can no longer install anything - // (`saveDocument` drops it) and must stop blocking; a project switch does not move the - // epoch, so there the stuck save can still land and the block correctly stays. - const unknownZoomSavesRef = useRef>([]); - const saveZoomPatch = useCallback( - (id: string, patch: Partial) => { - const epoch = currentWriteEpoch(); - const projectId = useProjectStore.getState().projectId; - return enqueueZoomWrite(async () => { - if (useProjectStore.getState().projectId !== projectId || currentWriteEpoch() !== epoch) { - return false; - } - if (unknownZoomSavesRef.current.filter((stuck) => stuck === epoch).length > 0) { - return false; - } - const doc = useProjectStore.getState().document; - if (!doc) return false; - const save = saveDocument( - { - ...doc, - zoomRanges: patchPillById(doc.zoomRanges, id, patch) as AxcutDocument["zoomRanges"], - }, - { history: true }, - ); - const outcome = await saveWithDeadline(save); - if (outcome !== "timeout") return outcome === true; - // Unknown, not failed: the write may still land. Report not-taken (the - // buttons retry) and refuse later writes into this same document generation - // until the save settles or the epoch moves past it. - unknownZoomSavesRef.current.push(epoch); - void save - .then( - () => undefined, - () => undefined, - ) - .finally(() => { - unknownZoomSavesRef.current = unknownZoomSavesRef.current.filter( - (stuck) => stuck !== epoch, - ); - }); - return false; - }); - }, - [enqueueZoomWrite, saveDocument], - ); + }, [queueZoomWrite, saveDocument]); // A preset level (`ZOOM_DEPTH_SCALES`, 1.25×–5×). It clears any custom scale, which // would otherwise keep overriding the depth: the level picked is the level rendered. From efacf2c86c6c6feb8e4e9991ed34c40fe8b26c98 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 25 Sep 2026 21:16:11 +0200 Subject: [PATCH 2/4] fix(timeline): put a focus back only over a write of its own epoch An abandoned focus drag left its last value behind. After an undo took that focus away, a bare commit put it back as a new undo step and wiped the redo. The value now carries the write epoch it was made in, and the commit ignores it once an undo or a replacement has moved past it. --- src/lib/ai-edition/store/useTimeline.test.ts | 27 ++++++++++++++++++++ src/lib/ai-edition/store/useTimeline.ts | 20 ++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index cd510dc83..787f07996 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -1353,6 +1353,33 @@ describe("useTimeline undo history", () => { expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(3); }); + // The commit's focus is put back only over a write of its own epoch. A drag abandoned + // (`ZoomFocusOverlay` unmounts when the focus mode flips to auto) and then undone must stay + // undone when a bare commit (`endDrag` with nothing in front of it) comes later. + it("does not put an abandoned drag's focus back after an undo", async () => { + seed(docWithZoom); + const { result } = renderTimeline(); + + await act(async () => { + await result.current.updateZoomDepth("zoom_a", 4); + }); + act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 })); + act(() => { + expect(undo()).toBe(true); + }); + expect(future).toHaveLength(1); + + await act(async () => { + await result.current.commitZoomFocus(); + }); + + expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({ + cx: 0.5, + cy: 0.5, + }); + expect(future).toHaveLength(1); + }); + // Rebase-review finding (queued zoom writes vs. document replacement): a zoom write // queued behind a still-pending one starts AFTER an undo has restored the document, // and must not apply its stale patch to the replacement. The in-flight write itself diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 259d15edc..25c37e82e 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -135,8 +135,13 @@ export function useTimeline() { // was never written. const zoomFocusRollbackRef = useRef(null); const zoomFocusLiveRef = useRef(null); - // And the focus drag's last value, which its commit puts back if a zoom write lands over it. - const zoomFocusEditRef = useRef<{ id: string; focus: { cx: number; cy: number } } | null>(null); + // And the focus drag's last value, which its commit puts back if a zoom write lands over it, + // with the write epoch it was made in. + const zoomFocusEditRef = useRef<{ + id: string; + focus: { cx: number; cy: number }; + epoch: number; + } | null>(null); const annotationRollbackRef = useRef(null); const annotationLiveRef = useRef(null); @@ -752,7 +757,11 @@ export function useTimeline() { // behind when the commit failed and rolled the document back to that very // document — a Ctrl+Z that visibly did nothing, with `future` already wiped. if (zoomFocusLiveRef.current !== doc) zoomFocusRollbackRef.current = doc; - const edit = { id, focus: { cx: finiteFraction(focus.cx), cy: finiteFraction(focus.cy) } }; + const edit = { + id, + focus: { cx: finiteFraction(focus.cx), cy: finiteFraction(focus.cy) }, + epoch: currentWriteEpoch(), + }; const next: AxcutDocument = { ...doc, zoomRanges: patchPillById(doc.zoomRanges, id, { @@ -774,7 +783,10 @@ export function useTimeline() { // place of the dragged one. const live = zoomFocusLiveRef.current; const rollback = zoomFocusRollbackRef.current; - const edit = zoomFocusEditRef.current; + // A drag abandoned before an undo or a replacement is not this commit's to put back: the + // document it was made on is gone, and restoring its focus would undo the undo. + const pendingEdit = zoomFocusEditRef.current; + const edit = pendingEdit?.epoch === currentWriteEpoch() ? pendingEdit : null; zoomFocusRollbackRef.current = null; zoomFocusLiveRef.current = null; zoomFocusEditRef.current = null; From 0c1f1a0adcf35bf410b264b86401a8cdab7ce648 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 25 Sep 2026 21:27:18 +0200 Subject: [PATCH 3/4] fix(timeline): roll a focus drag back when its commit fails late On the zoom chain the focus commit gives up waiting at the save deadline, and it skipped the rollback then: a save that failed afterwards left the dragged focus on screen for a later save to persist. The commit now waits for that answer and rolls back on a late failure, still only while the dragged document is on screen, and now only in its own epoch. --- src/lib/ai-edition/store/useTimeline.test.ts | 41 ++++++++++++++++++++ src/lib/ai-edition/store/useTimeline.ts | 30 ++++++++------ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 787f07996..d0a220095 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -1513,6 +1513,47 @@ describe("useTimeline undo history", () => { } }); + // A focus commit out of time leaves the zoom chain, but its save can still fail. The drag + // must come off screen then, as for any failed commit, or a later save persists it. + it("rolls a focus drag back when its commit fails after the deadline", async () => { + seed(docWithZoom); + vi.useFakeTimers(); + try { + let failHungSave: () => void; + bridgeMocks.save.mockImplementation( + () => + new Promise((resolve) => { + failHungSave = () => resolve({ success: false, error: "disk full" }); + }), + ); + const { result } = renderTimeline(); + + act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 })); + act(() => { + void result.current.commitZoomFocus(); + }); + // No answer by the deadline: the save may still land, so the drag stays. + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({ + cx: 0.8, + cy: 0.2, + }); + + await act(async () => { + failHungSave(); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({ + cx: 0.5, + cy: 0.5, + }); + } finally { + vi.useRealTimers(); + } + }); + // Rebase-review follow-up (unknown save × replacement): the refusal must not outlive // its reason. Once an undo bumps the epoch, the stuck save can no longer install // anything (`saveDocument` drops it), so it must stop blocking zoom writes — even if diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 25c37e82e..267917ca4 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -783,13 +783,15 @@ export function useTimeline() { // place of the dragged one. const live = zoomFocusLiveRef.current; const rollback = zoomFocusRollbackRef.current; + const epoch = currentWriteEpoch(); // A drag abandoned before an undo or a replacement is not this commit's to put back: the // document it was made on is gone, and restoring its focus would undo the undo. const pendingEdit = zoomFocusEditRef.current; - const edit = pendingEdit?.epoch === currentWriteEpoch() ? pendingEdit : null; + const edit = pendingEdit?.epoch === epoch ? pendingEdit : null; zoomFocusRollbackRef.current = null; zoomFocusLiveRef.current = null; zoomFocusEditRef.current = null; + const pending: { save?: Promise } = {}; const outcome = await queueZoomWrite((doc) => { // The snapshot counts only while the document on screen is still the one this // hook's last live write produced -- `updateZoomFocusLive`'s own identity test, @@ -824,18 +826,24 @@ export function useTimeline() { }; historyBase = doc; } - return saveDocument(next, { history: true, historyBase }); + pending.save = saveDocument(next, { history: true, historyBase }); + return pending.save; }); - if (outcome === false && rollback) { + // A save out of time has left the chain, but it can still fail: its answer, whenever it + // comes, decides the rollback, or the dragged focus stays on screen for a later save. + const saved = outcome === "timeout" && pending.save ? await pending.save : outcome; + if (saved === false && rollback) { useProjectStore.setState((state) => - // Only while the dragged document is still the one on screen: anything else - // there was put by a write that landed. `dirty` is deliberately NOT cleared. The - // rollback target is the last document this drag started from, which is not the - // same as the last SAVED one: with two commits in flight the first one's unsaved - // document is what we restore. Saying "clean" there tells `beforeunload` and - // `setHasUnsavedChanges` there is nothing to save, and the window closes on real - // work without prompting. - state.document === live ? { document: rollback, revision: state.revision + 1 } : {}, + // Only while the dragged document is still the one on screen, in the epoch it was + // committed in: anything else there was put by a write that landed, or by an undo. + // `dirty` is deliberately NOT cleared. The rollback target is the last document + // this drag started from, which is not the same as the last SAVED one: with two + // commits in flight the first one's unsaved document is what we restore. Saying + // "clean" there tells `beforeunload` and `setHasUnsavedChanges` there is nothing + // to save, and the window closes on real work without prompting. + state.document === live && currentWriteEpoch() === epoch + ? { document: rollback, revision: state.revision + 1 } + : {}, ); } }, [queueZoomWrite, saveDocument]); From 2426ca0bb813a24800df8e5e4b98cf6493d38e7a Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 25 Sep 2026 21:39:09 +0200 Subject: [PATCH 4/4] fix(timeline): let a zoom write that saves a live drag undo it too A zoom write run while a focus drag was on screen saved the dragged focus with it, recording the dragged document as its undo base. The commit then found the focus saved and recorded nothing, so no Ctrl+Z could bring the old focus back. Such a write now records the pre-drag document instead: one undo takes the drag off with it. --- src/lib/ai-edition/store/useTimeline.test.ts | 24 ++++++++++++++++++++ src/lib/ai-edition/store/useTimeline.ts | 19 +++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index d0a220095..0a671789a 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -1380,6 +1380,30 @@ describe("useTimeline undo history", () => { expect(future).toHaveLength(1); }); + // A zoom write that runs while a focus drag is on screen saves the dragged focus too, so the + // commit finds nothing left to record. That write's undo step has to reach back to before + // the drag, or no Ctrl+Z could ever bring the old focus back. + it("undoes a focus drag with the zoom write that saved it", async () => { + seed(docWithZoom); + const { result } = renderTimeline(); + + act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 })); + await act(async () => { + await result.current.updateZoomDepth("zoom_a", 4); + }); + await act(async () => { + await result.current.commitZoomFocus(); + }); + + act(() => { + expect(undo()).toBe(true); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ + depth: 3, + focus: { cx: 0.5, cy: 0.5 }, + }); + }); + // Rebase-review finding (queued zoom writes vs. document replacement): a zoom write // queued behind a still-pending one starts AFTER an undo has restored the document, // and must not apply its stale patch to the replacement. The in-flight write itself diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 267917ca4..a10af9ce5 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -661,7 +661,7 @@ export function useTimeline() { // epoch, so there the stuck save can still land and the block correctly stays. const unknownZoomSavesRef = useRef>([]); const queueZoomWrite = useCallback( - (write: (doc: AxcutDocument) => Promise) => { + (write: (doc: AxcutDocument, historyBase: AxcutDocument | undefined) => Promise) => { const epoch = currentWriteEpoch(); const projectId = useProjectStore.getState().projectId; return enqueueZoomWrite(async () => { @@ -673,7 +673,14 @@ export function useTimeline() { } const doc = useProjectStore.getState().document; if (!doc) return false; - const save = write(doc); + // A focus drag not yet committed is on screen, so this write saves it too, and the + // commit will find nothing left to record. Its undo step reaches back to before + // the drag instead, or the drag could never be undone. + const historyBase = + doc === zoomFocusLiveRef.current + ? (zoomFocusRollbackRef.current ?? undefined) + : undefined; + const save = write(doc, historyBase); const outcome = await saveWithDeadline(save); if (outcome === "timeout") { // Unknown, not failed: the write may still land. Refuse later writes into @@ -700,13 +707,13 @@ export function useTimeline() { // Reports not-taken for an unknown answer too, so the buttons retry. const saveZoomPatch = useCallback( (id: string, patch: Partial) => - queueZoomWrite((doc) => + queueZoomWrite((doc, historyBase) => saveDocument( { ...doc, zoomRanges: patchPillById(doc.zoomRanges, id, patch) as AxcutDocument["zoomRanges"], }, - { history: true }, + { history: true, historyBase }, ), ).then((outcome) => outcome === true), [queueZoomWrite, saveDocument], @@ -720,7 +727,7 @@ export function useTimeline() { (id: string, startMs: number, endMs: number) => { const s = finiteMs(startMs); const e = finiteMs(endMs); - return queueZoomWrite((doc) => + return queueZoomWrite((doc, historyBase) => saveDocument( { ...doc, @@ -733,7 +740,7 @@ export function useTimeline() { () => createId("zoom"), ) as AxcutDocument["zoomRanges"], }, - { history: true }, + { history: true, historyBase }, ), ); },