From 947fd6b1cb539ad7eba52be8a504a1f5ed4f9588 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 21:44:07 +0200 Subject: [PATCH] fix(sessions): spend each write marker only on the tool that wrote it Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../copilot/chat/global/core.test.ts | 21 +++++++++++++++++++ .../copilot/chat/global/userDraftAdapter.ts | 3 +++ frontend/src/lib/userDraft.svelte.ts | 7 +++++++ .../(root)/(logged)/sessions/+page.svelte | 12 +++++++++-- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 5c069f189f..a6c1d763f8 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2160,6 +2160,27 @@ describe('global AI tools', () => { expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(true) }) + // Nothing consumes a marker outside a session, so one can outlive the item it + // was about. Recreating the item must void it, or the next action on the + // recreated item reads it and sends a perfectly valid editor back to its list. + it('voids a draft-only removal marker when the item is written again', async () => { + const path = 'f/resources/discard-then-recreate' + await callGlobalTool('write_resource', { + path, + resource_type: 'postgresql', + value: { host: 'localhost' } + }) + await callGlobalTool('discard_local_draft', { type: 'resource', path }) + + // The marker is standing (no listener consumed it), and now the item is back. + await callGlobalTool('write_resource', { + path, + resource_type: 'postgresql', + value: { host: 'elsewhere' } + }) + expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(false) + }) + // "Create a resource, then never mind": delete_workspace_item must reject a path // that was never deployed, before the confirmation card — otherwise the user // confirms a workspace mutation that 404s past the draft cleanup, leaving the diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index f2c3f02431..3b1eeab8e8 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -421,6 +421,9 @@ export async function persistGlobalDraft( const itemKind = itemKindFor(type, opts.triggerKind) if (!itemKind) throw new Error(`Unsupported draft type "${type}".`) const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + // Writing the item back means it exists again, so any removal marker left from + // an earlier draft-only discard is void. + UserDraft.clearDraftOnlyDiscard(itemKind, storagePath, { workspace }) UserDraft.seed(itemKind, storagePath, value, { workspace }) await UserDraftDbSyncer.save({ workspace, diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 6a5385b2c7..aff6e3c932 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -442,6 +442,13 @@ export const UserDraft = { noteMarker(draftOnlyDiscards, mapKey(resolveWorkspace(opts), itemKind, path)) }, + /** Drop any removal marker for this cell — the item exists again, so a marker + * still standing (nothing consumed it, because nothing was listening) would be + * read by whatever touches it next and send its editor away. */ + clearDraftOnlyDiscard(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + draftOnlyDiscards.delete(mapKey(resolveWorkspace(opts), itemKind, path)) + }, + /** Whether the last discard for this cell removed the item outright (see * {@link recordDraftOnlyDiscard}), clearing the record. */ takeDraftOnlyDiscard( diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 3f730a40e8..fd675ad7be 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -625,12 +625,20 @@ // editor still loading holds none yet and the seed no-opped past it. The // miss is recorded when it happens — asking now would be too late, since // the editor can have acquired the cell during the write's round-trip. + // Each marker is read only by the kind of tool that writes it. A marker + // left unread — nothing consumes them outside a session — would otherwise + // be spent by whatever touched the item next, and a removal marker read by + // a deploy sends a live editor away. const kind = path ? entityKindForPage(pages[0]) : undefined - const seedReachedEditor = !kind || !path || !UserDraft.takeSeedMiss(kind, path, { workspace }) + const readable = !!kind && !!path + const seedReachedEditor = + !readable || entity !== 'none' || !UserDraft.takeSeedMiss(kind!, path!, { workspace }) // A discard of a draft-only item removed the item itself, so its editor has // nothing left to re-read and must leave instead. const itemSurvives = - !kind || !path || !UserDraft.takeDraftOnlyDiscard(kind, path, { workspace }) + !readable || + name !== 'discard_local_draft' || + !UserDraft.takeDraftOnlyDiscard(kind!, path!, { workspace }) const effect = effectForDiscard(effectForWrite(entity, seedReachedEditor), itemSurvives) if (effect !== 'none') pendingMutations.push({ pages, effect, path, workspace }) clearTimeout(reloadHandle)