diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 2d06dd9ef7..dd60746d07 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -1781,13 +1781,25 @@ export class AIChatManager { /** Queue the message typed while a turn is streaming. There is only ever * one queued message; pressing Enter again appends the new text as another * line so it all goes out as a single message, and its images accumulate - * alongside it. */ + * alongside it. + * + * Refused outright while another tab's run is on screen. `loading` is that + * tab's, and the turn that would drain this queue belongs to it, so anything + * parked here waits for an unrelated later turn of our own and then fires an + * instruction written against a workspace that has moved on. The composer is + * locked for the same reason; this is the same rule for the senders that + * never touch a composer — an editor's AI Fix, a raw-app inline prompt, an + * arriving hand-off. */ queueMessage( text: string, images: AttachedImage[] = [], context?: ContextElement[], files: AttachedTextFile[] = [] ) { + if (this.mirroringRemoteRun) { + sendUserToast('This session is running in another tab. Try again when it finishes.', true) + return + } const trimmed = text.trim() // An attachment-only or context-only draft is still a message; only a fully // empty send is ignored (mirrors the idle empty-send guard). diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 74d22391bc..b6b22e5991 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -416,6 +416,24 @@ describe('AIChatManager.sendOrQueue', () => { expect(manager.queuedMessage).toBe('') }) + // `loading` while another tab drives is that tab's, and the turn that drains + // this queue is its turn, not ours. Anything parked here would sit until some + // unrelated later turn of our own picked it up and ran an instruction written + // against a workspace that had moved on. The composer is locked for the same + // reason; these senders never touch a composer. + it('refuses to queue a programmatic prompt while another tab drives', () => { + const manager = new AIChatManager() + manager.isSessionChat = true + manager.sessionId = 'session-programmatic-queue' + noteDriverAlive('session-programmatic-queue', false) + manager.loading = true + + manager.sendOrQueue('deploy the fix') + + expect(mocks.runChatLoop).not.toHaveBeenCalled() + expect(manager.queuedMessage).toBe('') + }) + // `loading` only rises after a send's attachment upkeep, so gating on it alone // leaves a window where a second programmatic send slips through. it('queues during a send that has not reached loading yet', async () => { diff --git a/frontend/src/lib/components/sessions/sessionRunOwner.svelte.ts b/frontend/src/lib/components/sessions/sessionRunOwner.svelte.ts index bc7f74116f..74bafdbf55 100644 --- a/frontend/src/lib/components/sessions/sessionRunOwner.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRunOwner.svelte.ts @@ -193,10 +193,9 @@ async function drive(sessionId: string, body: () => Promise): Promise { * is gone, where silence alone only suggests it. */ async function runLockHeld(sessionId: string): Promise { // Nothing to consult without the lock API, so a silent driver is reaped on - // silence alone. That only ever frees the UI: reaching `idle` does not by - // itself entitle this tab to drive, because {@link bestEffort} probes for a - // live driver at the moment it matters rather than trusting a conclusion - // drawn from silence up to ten seconds earlier. + // silence alone — and on that path reaching `idle` does entitle this tab to + // drive, with everything that implies when the driver was merely throttled. + // See {@link bestEffort} for why that is accepted rather than defended. if (!EXCLUSIVE_OWNERSHIP) return false try { const state = await navigator.locks.query() diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 27045d4fbe..ffc492a5af 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -1214,7 +1214,15 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro } /** Backoff for a catch-up that could not read the store, capped so a long - * outage settles into polling rather than growing without bound. */ + * outage settles into polling rather than growing without bound. + * + * It retries for as long as the runtime lives, including against a store that + * will never open. Deliberate: the alternative is giving up and releasing the + * gate, and this tab would then send a mirrored transcript paired with pre-run + * history — the driver's completed turn missing from what reaches the model, + * and its record overwritten. A read every few seconds is the cheaper half of + * that trade, and a browser whose IndexedDB never opens has no session history, + * artifacts or records either, so a locked composer is not what is broken. */ const CATCH_UP_BACKOFF_MS = [300, 700, 1500, 3000, 5000] const catchUpRetries = new Map>()