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