diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 208942e27..2d9c3b53f 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -14,6 +14,7 @@ import { } from "@/lib/ai-edition/document/timeline"; import { type AxcutClip, documentSchema } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { saveTimelineMutation } from "@/lib/ai-edition/store/timelineSave"; import { useAssetTranscriptions, useAutoTranscription, @@ -358,6 +359,7 @@ export function NewEditorShell() { // stale-closure bugs. const known = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 60; const state = useProjectStore.getState(); + const persist = state.saveDocument; setSourceDuration(known); const doc = state.document; if (!doc || doc.assets.length === 0) return; @@ -380,7 +382,7 @@ export function NewEditorShell() { [{ startSec: 0, endSec: known }], "Auto-created full-duration clip", ); - void state.saveDocument(next); + void saveTimelineMutation(persist, next); return; } // Hand the probed duration to the pure document layer: it patches only the @@ -391,7 +393,7 @@ export function NewEditorShell() { // nothing is waiting, so there is nothing to guard here. const next = applyProbedDuration(doc, assetId, known); if (next !== doc) { - void state.saveDocument(next); + void saveTimelineMutation(persist, next); } }, [setSourceDuration], diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 22b79aa8d..96fe96f59 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -1059,6 +1059,7 @@ export function V4Timeline({ return; } const added = await tl.addZoomsBulk(suggestions); + if (added === 0) return; toast.success( t(added === 1 ? "toolbar.addedAutoZoom" : "toolbar.addedAutoZoomPlural", { count: added }), ); diff --git a/src/lib/ai-edition/store/timelineSave.ts b/src/lib/ai-edition/store/timelineSave.ts new file mode 100644 index 000000000..075dcaf29 --- /dev/null +++ b/src/lib/ai-edition/store/timelineSave.ts @@ -0,0 +1,24 @@ +import { toast } from "sonner"; +import type { AxcutDocument } from "../schema"; + +type SaveDocument = (document: AxcutDocument) => Promise; + +/** + * Persist a user-initiated timeline mutation without letting a detached caller + * turn a write failure into an unhandled rejection. + */ +export async function saveTimelineMutation( + saveDocument: SaveDocument, + document: AxcutDocument, +): Promise { + try { + await saveDocument(document); + return true; + } catch (error) { + console.error("[timeline] failed to save mutation:", error); + toast.error("Save failed", { + description: error instanceof Error ? error.message : String(error), + }); + return false; + } +} diff --git a/src/lib/ai-edition/store/useSequentialTimelineOps.test.ts b/src/lib/ai-edition/store/useSequentialTimelineOps.test.ts index f86088bcf..4b85a62d5 100644 --- a/src/lib/ai-edition/store/useSequentialTimelineOps.test.ts +++ b/src/lib/ai-edition/store/useSequentialTimelineOps.test.ts @@ -18,6 +18,10 @@ import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema import { useProjectStore } from "./projectStore"; import { useSequentialTimelineOps } from "./useSequentialTimelineOps"; +const toastErrorMock = vi.hoisted(() => vi.fn()); + +vi.mock("sonner", () => ({ toast: { error: toastErrorMock } })); + function makeDocWithAsset(): AxcutDocument { const base = createEmptyDocument({ projectId: "proj_seq", title: "seq" }); return { @@ -55,6 +59,7 @@ function makeDocWithAsset(): AxcutDocument { beforeEach(() => { useProjectStore.getState().clear(); + toastErrorMock.mockReset(); }); afterEach(() => { @@ -112,7 +117,7 @@ describe("useSequentialTimelineOps", () => { expect(doc2.timeline.trimRanges.map((t) => t.startSec).sort()).toEqual([1, 5]); }); - it("swallows save errors so the next call can still proceed", async () => { + it("surfaces save errors without rejecting or poisoning the queue", async () => { const seed = makeDocWithAsset(); useProjectStore.setState({ document: seed }); @@ -140,24 +145,23 @@ describe("useSequentialTimelineOps", () => { reason: "second", }; - let firstSettled = false; - let secondSettled = false; + let firstResult: AxcutDocument | null | undefined; + let secondResult: AxcutDocument | null | undefined; await act(async () => { const p1 = result.current.apply(op1); const p2 = result.current.apply(op2); - await p1.catch((err: unknown) => { - firstSettled = true; - expect((err as Error).message).toBe("save failed"); - }); - await p2.then(() => { - secondSettled = true; - }); + firstResult = await p1; + secondResult = await p2; }); - expect(firstSettled).toBe(true); - expect(secondSettled).toBe(true); + expect(firstResult).toBeNull(); + expect(secondResult).not.toBeNull(); + expect(toastErrorMock).toHaveBeenCalledWith("Save failed", { + description: "save failed", + }); // The queue survived the first failure — both saves were attempted. expect(saveDocument).toHaveBeenCalledTimes(2); + expect(saveDocument).toHaveBeenNthCalledWith(2, secondResult); }); it("returns null when the store has no document and no fallback is supplied", async () => { diff --git a/src/lib/ai-edition/store/useSequentialTimelineOps.ts b/src/lib/ai-edition/store/useSequentialTimelineOps.ts index 642c5a435..c9c25383f 100644 --- a/src/lib/ai-edition/store/useSequentialTimelineOps.ts +++ b/src/lib/ai-edition/store/useSequentialTimelineOps.ts @@ -7,15 +7,15 @@ // read the doc INSIDE the chain, after awaiting the previous save, so // every call sees the doc state the previous call committed. // -// Errors are swallowed when advancing the queue ref so a failed save -// doesn't poison the queue (the next call still has a resolved promise -// to chain off). The original promise returned to the caller is NOT -// swallowed — the caller can await it and observe the rejection. +// Save failures are surfaced once by the shared mutation boundary and resolve +// to null. This keeps detached UI calls from emitting unhandled rejections and +// also leaves the queue healthy for the next edit. import { useCallback, useRef } from "react"; import type { AxcutTimelineOperation } from "@/lib/ai-edition/document/operations"; import type { AxcutDocument } from "@/lib/ai-edition/schema"; import { useProjectStore } from "./projectStore"; +import { saveTimelineMutation } from "./timelineSave"; export interface SequentialTimelineOps { /** @@ -25,7 +25,7 @@ export interface SequentialTimelineOps { * saved. Calls are serialised — op N+1 reads the doc op N wrote. * * Returns the saved document, or `null` if no project document is - * loaded (store empty AND no fallback supplied). + * loaded (store empty AND no fallback supplied) or the save fails. */ apply: (op: AxcutTimelineOperation) => Promise; } @@ -43,7 +43,7 @@ export function useSequentialTimelineOps(options: { (op: AxcutTimelineOperation): Promise => { const queued = saveQueueRef.current .then(() => import("@/lib/ai-edition/document/operations")) - .then(({ applyTimelineOperation }) => { + .then(async ({ applyTimelineOperation }) => { // Read the doc inside the chain. The store holds the // latest committed state because the previous call's // save has already resolved by the time this .then @@ -51,13 +51,11 @@ export function useSequentialTimelineOps(options: { const doc = useProjectStore.getState().document ?? fallbackDocument; if (!doc) return null; const applied = applyTimelineOperation(doc, op); - return saveDocument(applied.document).then(() => applied.document); + const saved = await saveTimelineMutation(saveDocument, applied.document); + return saved ? applied.document : null; }); - // Swallow rejection when advancing the queue so a failed save - // doesn't poison the queue — the next call still has a - // resolved promise to chain off. The original `queued` is - // returned to the caller, who can await it and observe the - // rejection. + // Keep operation/import errors from poisoning the queue. Save + // failures already resolve to null after showing user feedback. saveQueueRef.current = queued.then( () => undefined, () => undefined, diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 7f4541768..52f39d5e0 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -18,6 +18,9 @@ const probeVideoDurationMock = vi.hoisted(() => vi.fn()); const probeVideoDimensionsMock = vi.hoisted(() => vi.fn().mockResolvedValue({ width: 1920, height: 1080 }), ); +const toastErrorMock = vi.hoisted(() => vi.fn()); + +vi.mock("sonner", () => ({ toast: { error: toastErrorMock } })); vi.mock("../timeline/duration", async (importOriginal) => { const actual = await importOriginal(); @@ -546,6 +549,34 @@ describe("useTimeline zoom modifiers (rotation + focus mode)", () => { focusMode: "auto", }); }); + + it("rolls a live focus edit back when its commit cannot be saved", async () => { + bridgeMocks.save.mockResolvedValueOnce({ success: false, error: "project file locked" }); + const { result } = renderTimeline(); + + act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 })); + expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({ + cx: 0.8, + cy: 0.2, + }); + + await act(async () => { + await result.current.commitZoomFocus(); + }); + + expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({ + cx: 0.5, + cy: 0.5, + }); + expect(useProjectStore.getState().dirty).toBe(false); + // The live edit advanced revision once; restoring a different document + // advances it again so async work cannot mistake the rollback for the + // optimistic document it replaced. + expect(useProjectStore.getState().revision).toBe(3); + expect(toastErrorMock).toHaveBeenCalledWith("Save failed", { + description: "project file locked", + }); + }); }); // Regression guard for the playhead-stutter fix. `currentTimeSec` is rewritten on @@ -669,3 +700,33 @@ describe("useTimeline selection", () => { expect(result.current.clipSelection).toBeNull(); }); }); + +describe("useTimeline save failures", () => { + beforeEach(() => { + useProjectStore.getState().clear(); + for (const mock of Object.values(bridgeMocks)) mock.mockReset(); + toastErrorMock.mockReset(); + bridgeMocks.save.mockResolvedValue({ success: false, error: "disk full" }); + useProjectStore.setState({ + projectId: "proj_test", + document: sampleDoc, + revision: 1, + status: "ready", + error: null, + }); + }); + + it("surfaces a rejected mutation without applying it or leaking the rejection", async () => { + const { result } = renderTimeline(); + + await act(async () => { + await expect(result.current.removeClip("clip_a")).resolves.toBeUndefined(); + }); + + expect(toastErrorMock).toHaveBeenCalledWith("Save failed", { + description: "disk full", + }); + expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(1); + expect(useProjectStore.getState().document?.timeline.clips[0]?.id).toBe("clip_a"); + }); +}); diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index cd763bae1..98038eca2 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -30,6 +30,7 @@ import { import { dropTrimPillsByIds, resolveTimelineSpanToTrim } from "../timeline/trim-mapping"; import type { AutoZoomSuggestion } from "../timeline/zoom-suggestions"; import { useProjectStore } from "./projectStore"; +import { saveTimelineMutation } from "./timelineSave"; // How long a region lasts when the caller doesn't say. The timeline's toolbar // passes its own duration instead, derived from the current zoom so the new pill @@ -96,7 +97,11 @@ export function useTimeline() { const ts = useScopedT("settings"); const document = useProjectStore((s) => s.document); const projectId = useProjectStore((s) => s.projectId); - const saveDocument = useProjectStore((s) => s.saveDocument); + const saveProjectDocument = useProjectStore((s) => s.saveDocument); + const saveDocument = useCallback( + (document: AxcutDocument) => saveTimelineMutation(saveProjectDocument, document), + [saveProjectDocument], + ); const setDocument = useProjectStore((s) => s.setDocument); const [selection, setSelection] = useState(null); // F2.7 — shift-click multi-selection. `selection` stays the inspector's @@ -104,6 +109,10 @@ export function useTimeline() { // the Delete key operates on. const [multiSelection, setMultiSelection] = useState([]); const [clipSelection, setClipSelection] = useState(null); + const zoomFocusRollbackRef = useRef(null); + const zoomFocusLiveRef = useRef(null); + const annotationRollbackRef = useRef(null); + const annotationLiveRef = useRef(null); const hasDoc = document !== null && projectId !== null; @@ -217,7 +226,7 @@ export function useTimeline() { ...document, zoomRanges: [...document.zoomRanges, ...anchored] as AxcutDocument["zoomRanges"], }; - await saveDocument(next); + if (!(await saveDocument(next))) return 0; return suggestions.length; }, [document, saveDocument], @@ -305,7 +314,7 @@ export function useTimeline() { ...created, ] as unknown as AxcutDocument["annotations"], }; - await saveDocument(next); + if (!(await saveDocument(next))) return; // Select the freshly added annotation so its inspector opens and it shows a // selection box on the canvas, ready to be retyped over. const newId = created[0]?.id ?? ann.id; @@ -487,6 +496,7 @@ export function useTimeline() { (id: string, focus: { cx: number; cy: number }) => { const doc = useProjectStore.getState().document; if (!doc) return; + if (zoomFocusLiveRef.current !== doc) zoomFocusRollbackRef.current = doc; const next: AxcutDocument = { ...doc, zoomRanges: patchPillById(doc.zoomRanges, id, { @@ -494,6 +504,7 @@ export function useTimeline() { }) as AxcutDocument["zoomRanges"], }; setDocument(next); + zoomFocusLiveRef.current = next; }, [setDocument], ); @@ -501,7 +512,16 @@ export function useTimeline() { const commitZoomFocus = useCallback(async () => { const doc = useProjectStore.getState().document; if (!doc) return; - await saveDocument(doc); + const rollback = zoomFocusRollbackRef.current; + zoomFocusRollbackRef.current = null; + zoomFocusLiveRef.current = null; + if (!(await saveDocument(doc)) && rollback) { + useProjectStore.setState((state) => + state.document === doc + ? { document: rollback, revision: state.revision + 1, dirty: false } + : {}, + ); + } }, [saveDocument]); // Zoom-level control for the region-settings panel (1-6, matches @@ -590,11 +610,13 @@ export function useTimeline() { (id: string, patch: Partial) => { const doc = useProjectStore.getState().document; if (!doc) return; + if (annotationLiveRef.current !== doc) annotationRollbackRef.current = doc; const next: AxcutDocument = { ...doc, annotations: patchPillById(doc.annotations, id, patch), }; setDocument(next); + annotationLiveRef.current = next; }, [setDocument], ); @@ -602,7 +624,16 @@ export function useTimeline() { const commitAnnotationChange = useCallback(async () => { const doc = useProjectStore.getState().document; if (!doc) return; - await saveDocument(doc); + const rollback = annotationRollbackRef.current; + annotationRollbackRef.current = null; + annotationLiveRef.current = null; + if (!(await saveDocument(doc)) && rollback) { + useProjectStore.setState((state) => + state.document === doc + ? { document: rollback, revision: state.revision + 1, dirty: false } + : {}, + ); + } }, [saveDocument]); const updateSpeedSpan = useCallback( @@ -692,7 +723,7 @@ export function useTimeline() { async (kind: RegionKind, id: string) => { if (!document) return; // One shared mutator with the agent's removeTrim / removeModifier tools. - await saveDocument(removeRegionInDocument(document, kind, id)); + if (!(await saveDocument(removeRegionInDocument(document, kind, id)))) return; if (selection?.id === id) setSelection(null); setMultiSelection((prev) => prev.filter((h) => h.id !== id)); }, @@ -743,7 +774,7 @@ export function useTimeline() { ? { ...legacy, speedRegions: prevSpeed, cameraFullscreenRegions: prevCameraFullscreen } : document.legacyEditor, }; - await saveDocument(next); + if (!(await saveDocument(next))) return; setSelection(null); setMultiSelection([]); }, @@ -926,7 +957,7 @@ export function useTimeline() { timeline: { ...currentDoc.timeline, clips: newClips }, }; const finalDoc = rederiveRegionMs(next, newClips); - await saveDocument(finalDoc); + if (!(await saveDocument(finalDoc))) return; setClipSelection(newClip.id); // If we used the placeholder, kick off the probe in the background. @@ -971,7 +1002,7 @@ export function useTimeline() { // original, so its index in the result is the original's index + 1. const insertedIndex = document.timeline.clips.findIndex((c) => c.id === clipId) + 1; const next = duplicateClipInDocument(document, clipId, "user", "Duplicated clip"); - await saveDocument(next); + if (!(await saveDocument(next))) return; setClipSelection(next.timeline.clips[insertedIndex]?.id ?? null); }, [document, saveDocument], @@ -981,7 +1012,7 @@ export function useTimeline() { async (clipId: string) => { if (!document) return; // One shared mutator with the agent's removeClip tool: reflow survivors + rederive pills. - await saveDocument(removeClipInDocument(document, clipId)); + if (!(await saveDocument(removeClipInDocument(document, clipId)))) return; if (clipSelection === clipId) setClipSelection(null); }, [document, clipSelection, saveDocument],