From 21240cf6f9abdaf39b45d911c9bbc1a2f533eada Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 26 Oct 2026 10:40:44 -0700 Subject: [PATCH] fix: re-read the session's drafts once a new fork exists Creating the fork takes seconds, during which an edit in the session's preview can save a draft in the parent that the move's list did not have. The move now flushes and lists the drafts again before the transfer, and reopens the dialog on the created fork when they changed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/sessions/SessionWrapper.svelte | 17 +++++++++++++-- .../sessions/moveSessionToFork.test.ts | 19 +++++++++++++++++ .../components/sessions/moveSessionToFork.ts | 21 ++++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 63d2b5594f..bd9dcfb075 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -567,13 +567,26 @@ } return } + let relisted: Awaited> | undefined const res = await moveSessionToFork(session.id, req.parent, req.target, { items: req.items.map(({ kind, path }) => ({ kind, path })), removeFromParent: moveRemoveFromParent, - isBusy: () => !canMoveToFork + isBusy: () => !canMoveToFork, + itemsChanged: async () => { + relisted = await listMoveItems(req.parent, req.target) + return typeof relisted === 'string' || itemKeys(relisted.items) !== itemKeys(req.items) + } }) if (!res.ok) { - if (!req.open) { + if (res.changed && relisted !== undefined) { + moveRequest = { + ...req, + ...(typeof relisted === 'string' ? {} : relisted), + target: res.retry ?? req.target, + open: true, + error: typeof relisted === 'string' ? relisted : res.error + } + } else if (!req.open) { sendUserToast(res.error, true) moveRequest = undefined } else { diff --git a/frontend/src/lib/components/sessions/moveSessionToFork.test.ts b/frontend/src/lib/components/sessions/moveSessionToFork.test.ts index f044802946..b3e3641c10 100644 --- a/frontend/src/lib/components/sessions/moveSessionToFork.test.ts +++ b/frontend/src/lib/components/sessions/moveSessionToFork.test.ts @@ -117,4 +117,23 @@ describe('moveSessionToFork', () => { }) } ) + + it('stops before transferring when the drafts changed while the fork was created', async () => { + vi.mocked(materializeForkOutcome).mockResolvedValue({ id: 'wm-fork-new', created: true }) + vi.mocked(DraftService.transferDrafts).mockClear() + const s = addSession() + const res = await moveSessionToFork( + s.id, + 'root_ws', + { kind: 'new', fork: { id: 'wm-fork-new', name: 'new', parent_workspace_id: 'root_ws' } }, + { items: [], removeFromParent: false, itemsChanged: async () => true } + ) + expect(res).toMatchObject({ + ok: false, + changed: true, + retry: { kind: 'existing', id: 'wm-fork-new', replace: true } + }) + expect(DraftService.transferDrafts).not.toHaveBeenCalled() + expect(sessionState.sessions.find((x) => x.id === s.id)?.workspace_id).toBe('root_ws') + }) }) diff --git a/frontend/src/lib/components/sessions/moveSessionToFork.ts b/frontend/src/lib/components/sessions/moveSessionToFork.ts index 00bde2dd88..9d0c219784 100644 --- a/frontend/src/lib/components/sessions/moveSessionToFork.ts +++ b/frontend/src/lib/components/sessions/moveSessionToFork.ts @@ -24,7 +24,14 @@ export type MoveTarget = export type MoveSessionResult = | { ok: true; forkId: string; copied: number; removed: number } // `retry` is the target to retry with once the fork exists, so a retry never creates it again. - | { ok: false; error: string; conflicts?: DraftItemRef[]; retry?: MoveTarget } + // `changed`: the session's drafts are no longer the ones listed, so the caller lists them again. + | { + ok: false + error: string + conflicts?: DraftItemRef[] + retry?: MoveTarget + changed?: boolean + } /** * Send every debounced draft save for `workspace` to the server. Returns an error message @@ -89,6 +96,8 @@ export async function moveSessionToFork( removeFromParent: boolean // A turn running in the parent keeps writing there while its drafts move away. isBusy?: () => boolean + // Saves pending in the parent and tells whether the session's drafts differ from `items`. + itemsChanged?: () => Promise } ): Promise { const unsaved = await flushPendingSaves(parent) @@ -103,6 +112,16 @@ export async function moveSessionToFork( fork = { kind: 'existing', id: made.id, replace: made.created } // Creating the fork takes seconds, long enough for a job's auto-resume to start a turn. if (opts.isBusy?.()) return { ok: false, error: busy, retry: fork } + // So does an edit in the session's preview: it lands in the parent, possibly as a draft the + // list did not have. + if (await opts.itemsChanged?.()) { + return { + ok: false, + error: "This session's drafts changed. Check the list and confirm again.", + retry: fork, + changed: true + } + } } else { fork = target // An editor open in the fork may hold edits not yet saved. Saved now, they become the