From 61eaabba933d013197573a54411592c196fb0db5 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 26 Aug 2026 18:25:24 +0200 Subject: [PATCH] fix(ai-sessions): keep the final mirror frame inside its turn, sync plan mode Co-Authored-By: Claude Opus 5 --- .../copilot/chat/AIChatDisplay.svelte | 31 ++++++++++++++++--- .../copilot/chat/AIChatManager.svelte.ts | 18 ++++++++++- .../copilot/chat/HistoryManager.svelte.ts | 9 ++++-- .../copilot/chat/HistoryManager.test.ts | 25 +++++++++++++++ .../sessions/sessionRuntime.svelte.ts | 26 +++++++++++++--- .../components/sessions/sessionSync.svelte.ts | 5 +++ 6 files changed, 103 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index c79a793bb7..34daf4caf5 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -501,10 +501,15 @@ ) // Fall back to ask-permission when the persisted mode isn't applicable in the // current AI mode (e.g. auto-accept edits while in a mode without edits). + // A session planning in another tab overrides both: that posture governs the + // run this tab is showing, and this tab's own mode governs nothing until it + // drives a turn itself. const effectiveAutonomyMode = $derived( - availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode) - ? aiChatManager.autonomyMode - : AIAutonomyMode.DEFAULT + aiChatManager.mirroredPlanMode + ? AIAutonomyMode.PLAN + : availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode) + ? aiChatManager.autonomyMode + : AIAutonomyMode.DEFAULT ) const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1) const effectiveAutonomyModeOption = $derived(autonomyModeOption(effectiveAutonomyMode)) @@ -955,7 +960,25 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> onchange={onFolderInputChange} /> {/if} - {#if showAutonomyModeSelector} + {#if aiChatManager.mirroredPlanMode} + + + {:else if showAutonomyModeSelector} availableAutonomyModeOptions.map((option) => ({ diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index be0fbb27aa..d7e0c44f2b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -701,6 +701,19 @@ export class AIChatManager { * session runtime. */ mirroringRemoteRun = $state(false) + /** Whether the tab driving this session was last seen in plan mode. + * + * Plan mode is the one autonomy state that is deliberately never persisted + * (see `persistAutonomyMode`): a model entered it for this session, so it + * lives only in the memory of the tab running the turn. Every other mode is + * a stored preference each tab is entitled to its own copy of. + * + * Kept after the turn ends rather than cleared with `mirroringRemoteRun`, + * because the driver stays in plan mode between turns and stops sending + * frames that could say so. The next turn's first frame corrects it; this + * tab driving one of its own clears it outright. */ + mirroredPlanMode = $state(false) + /** Whether the turn that just finished is one a follow-up should be sent * after: it committed, or the user deliberately stopped it. False through a * provider error, an empty-response rollback, or a programmatic cancel — the @@ -3724,7 +3737,10 @@ export class AIChatManager { // saveChat no-ops on an empty transcript; the chat persisted earlier // this turn would linger in history and resurface the rolled-back // user message on reload. Remove it instead. - this.historyManager.deletePastChat(this.historyManager.getCurrentChatId()) + // Awaited: the turn-end this rollback is about to announce sends the + // other tabs back to the store for this chat, and a delete still in + // flight leaves them the transcript being rolled back. + await this.historyManager.deletePastChat(this.historyManager.getCurrentChatId()) } else { await this.historyManager.saveChat( this.displayMessages, diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index c3e4c53728..dabaf96157 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -588,11 +588,16 @@ export default class HistoryManager { this.pruneImageIds(this.currentChatId) } - deletePastChat(id: string) { + /** Returns once the row is actually gone, for the one caller that has to know: + * a rolled-back turn announces its end to the other tabs, and they re-read + * this chat from the store. Dropped in flight, that read still finds the + * transcript the rollback exists to remove. Everywhere else the removal is + * visible from `savedChats` at once and the promise can be ignored. */ + deletePastChat(id: string): Promise { this.savedChats = Object.fromEntries( Object.entries(this.savedChats).filter(([key]) => key !== id) ) - void this.enqueueDbWrite(async (db) => { + return this.enqueueDbWrite(async (db) => { await db.delete('chats', id) const keys = await imageKeysForChat(db, id) await Promise.all(keys.map((key) => db.delete('images', key))) diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts index ffcc065157..a93f972029 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts @@ -387,6 +387,31 @@ describe('HistoryManager legacy chat-history migration', () => { }) }) + it('resolves deletePastChat behind a checkpoint still in flight', async () => { + const hm = new HistoryManager() + await hm.init() + const chatId = hm.getCurrentChatId() + + // The shape a rolled-back turn actually ends in: a mid-turn checkpoint is + // still queued when the rollback removes the chat. + const checkpoint = hm.saveChat( + [{ role: 'user', content: 'rolled back' }] as DisplayMessage[], + [] as ChatCompletionMessageParam[] + ) + const removed = hm.deletePastChat(chatId) + await removed + + // Read with no waitFor on purpose: the rollback announces its turn-end to the + // other tabs as soon as this resolves, and they go straight to the store for + // this chat. Resolving ahead of the queue hands them the very transcript the + // rollback exists to remove. + const db = await openDB('copilot-chat-history::admin@test') + const stored = await db.get('chats' as never, chatId) + db.close() + expect(stored).toBeUndefined() + await checkpoint + }) + it('loads pre-blob-store records with inline data URLs untouched', async () => { const png = 'data:image/png;base64,LEGACYINLINE' const hm = new HistoryManager() diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 91c7d74cab..b2e0cbb1dd 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -936,6 +936,9 @@ async function initRuntime(runtime: SessionRuntime, session: Session) { // turns interleave into one chat id. manager.runGuard = async (body) => { const outcome = await withSessionRunLock(session.id, async () => { + // Driving under this tab's own posture from here on, so whatever the + // last driver was in stops describing the session. + manager.mirroredPlanMode = false // The first frame doubles as the "a run started here" signal: it is // posted immediately and carries the chat id the watchers need. startMirroring(session.id) @@ -1054,7 +1057,8 @@ function mirrorSnapshotOf(sessionId: string, full: boolean): MirrorSnapshot | un currentReasoning: m.currentReasoning, currentReasoningActive: m.currentReasoningActive, loadingLabel: m.loadingLabel, - compacting: m.compacting + compacting: m.compacting, + planModeActive: m.planModeActive } } @@ -1080,11 +1084,15 @@ function stopMirroring(sessionId: string): void { if (!timer) return clearInterval(timer) mirrorTimers.delete(sessionId) - lastSentTotals.delete(sessionId) - turnStarts.delete(sessionId) // One last frame: the closing tokens of a turn usually land between ticks, // and this is what the passive tabs render until their re-read completes. + // Posted while the turn's bookkeeping still stands — it is a frame like any + // other, and one sent without `turnStart` reaches below the turn and replaces + // complete messages with payload-stripped copies, while one sent without + // `lastSentTotals` cannot notice a compaction landing on this very tick. postMirror(sessionId) + lastSentTotals.delete(sessionId) + turnStarts.delete(sessionId) } /** Adopt a frame from the tab driving this session. */ @@ -1133,6 +1141,7 @@ function applyMirror(msg: MirrorMsg): void { m.currentReasoningActive = msg.currentReasoningActive m.loadingLabel = msg.loadingLabel m.compacting = msg.compacting + m.mirroredPlanMode = msg.planModeActive } /** The driver answers a resync with its whole transcript. */ @@ -1187,7 +1196,16 @@ async function applyTurnEnd(sessionId: string, chatId: string, committed: boolea // than sent. Send it only after a turn that landed, which is the rule a turn // follows locally: firing it into a failed turn, or into the gap left by a tab // that vanished, is how a follow-up ends up answering nothing. - if (committed) await m.flushQueuedMessage() + if (!committed) return + if (m.mirroredPlanMode) { + // Plan mode belongs to the tab that entered it, and a turn sent from here + // would run under this tab's own autonomy instead — unblocking the very + // workspace tools the posture exists to hold back. Leave the message where + // the user put it and say why, rather than quietly sending it out of mode. + sendUserToast('This session is planning in another tab. Your message stays queued.') + return + } + await m.flushQueuedMessage() } /** A Stop pressed in a watching tab reaches the run here. */ diff --git a/frontend/src/lib/components/sessions/sessionSync.svelte.ts b/frontend/src/lib/components/sessions/sessionSync.svelte.ts index 91c723f5bb..582bfa3ecf 100644 --- a/frontend/src/lib/components/sessions/sessionSync.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSync.svelte.ts @@ -66,6 +66,11 @@ type MirrorMsg = { currentReasoningActive: boolean loadingLabel: string | undefined compacting: boolean + /** The driver's plan-mode posture. The only autonomy state worth carrying: + * every other one is a stored preference each tab keeps its own copy of, + * while plan mode is never persisted and so exists nowhere but the driving + * tab's memory. */ + planModeActive: boolean } /** Sent by a tab whose local prefix can't host the tail it just received (it * joined mid-run, or is on a different chat). The driver answers with a full