diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 0ed8cbe88e..422cc6de43 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2005,6 +2005,10 @@ export class AIChatManager { options.files ) } + // Handed off, so this manager no longer holds it. Only sendRequestImpl clears + // the field and a refusal never reaches it; left set, it reads to the + // auto-resume guard and the sidebar's draft cue as text the user is writing. + this.instructions = '' } /** Send the queued message, if there is one, as its own turn. The queue only diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index e92f7b3573..11b36dbb14 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -265,9 +265,10 @@ describe('AIChatManager cross-tab run guard', () => { expect(mocks.runChatLoop).not.toHaveBeenCalled() expect(manager.displayMessages).toHaveLength(2) expect(manager.messages).toHaveLength(2) - // The prompt is handed back rather than lost, so the retry is still the - // user's to run once the other tab finishes. - expect(manager.instructions).toBe('first') + // Handed back rather than lost — and off the manager, which reports a + // non-empty `instructions` to the sidebar as text the user is writing. + expect(manager.queuedMessage).toBe('first') + expect(manager.instructions).toBe('') }) // A turn flushes its queued message by re-entering sendRequest, and the lock diff --git a/frontend/src/lib/components/sessions/sessionRunOwner.svelte.ts b/frontend/src/lib/components/sessions/sessionRunOwner.svelte.ts index 186436de6e..a4624fe2e8 100644 --- a/frontend/src/lib/components/sessions/sessionRunOwner.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRunOwner.svelte.ts @@ -187,8 +187,8 @@ export async function withSessionRunLock( } } -/** What exclusion amounts to with no lock to take: refuse while another tab's run - * is visibly on screen, and otherwise go. +/** What exclusion amounts to with no lock to take: nothing beyond the refusal + * `withSessionRunLock` already made on a visibly running turn. * * Not mutual exclusion, and cannot be made into it — a throttled hidden driver * goes silent before its turn ends and is reaped as dead, and no probe tells a diff --git a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts index 06c98e5346..202a7f4dd1 100644 --- a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts +++ b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts @@ -278,8 +278,6 @@ describe('sessionState IndexedDB persistence', () => { await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['keep'])) }) - // A mirrored delete that announced itself would be mirrored straight back, and - // the two tabs would trade the same message and transaction without end. it('removes a mirrored delete without announcing it', async () => { const user = freshUser() await login(user) @@ -289,8 +287,7 @@ describe('sessionState IndexedDB persistence', () => { await deleteSessionRecord('mirrored', false) expect(deleteBroadcasts).toEqual([]) - // Still genuinely removed — silence is not a no-op, it is what collects the - // row a write racing the other tab's delete left behind. + // Silence is not a no-op: the row still goes. await rehydrate(user) await vi.waitFor(() => expect(sessionState.sessions).toEqual([])) })