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