fix(ai-sessions): keep the final mirror frame inside its turn, sync plan mode

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-26 18:25:24 +02:00
co-authored by Claude Opus 5
parent 3b9322d947
commit 61eaabba93
6 changed files with 103 additions and 11 deletions
@@ -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}
<!-- The posture belongs to the tab running the turn, so it reads here
rather than offering a choice that would only move this tab's own
preference while the label stayed put. -->
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="default"
disabled
title="This session is planning in another tab"
btnClasses={effectiveAutonomyModeOption.triggerClass ?? ''}
startIcon={{
icon: effectiveAutonomyModeOption.icon,
classes: effectiveAutonomyModeOption.iconColor
}}
>
{autonomyModeLabel(effectiveAutonomyMode)}
</Button>
{:else if showAutonomyModeSelector}
<DropdownV2
items={() =>
availableAutonomyModeOptions.map((option) => ({
@@ -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,
@@ -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<void> {
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)))
@@ -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()
@@ -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. */
@@ -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