fix(ai-sessions): clear dropped record keys and suppress the echo by position

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-31 18:42:19 +02:00
co-authored by Claude Opus 5
parent 624a716a48
commit 6e3b483053
7 changed files with 115 additions and 63 deletions
@@ -26,12 +26,7 @@
import { fade } from 'svelte/transition'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import {
pendingUserAction,
pendingUserActionDetail,
RUN_PROMPT_ECHO_MAX,
type DisplayMessage
} from './shared'
import { pendingUserAction, pendingUserActionDetail, type DisplayMessage } from './shared'
import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import type { ContextElement } from './context'
@@ -557,25 +552,19 @@
// of that turn reaches this tab's transcript until it ends, so without this
// the spinner has no subject.
//
// Suppressed once the transcript already carries the message: a tab that
// mounted after the driver's opening save reads it from the store, and the
// echo beside it would be the same text drawn twice.
// Suppressed once this tab's transcript reaches the run's first message: a tab
// that mounted after the driver's opening save reads it from the store, and
// the echo beside it would be the same message drawn twice.
//
// Compared whole, and by prefix only where the echo was cut at the source's
// ceiling and a prefix is all there is to compare. Matching on prefix
// unconditionally would hide the echo whenever a new prompt opens with the
// previous turn's text ("Fix" after "Fix the bug") — which is the case the
// sender pins by position precisely to keep.
// By position, not by text. Whether this tab holds the message is a fact about
// how far its transcript goes, and the same prompt sent twice running ("retry",
// "continue") is a new message that merely reads like the one above it —
// comparing text hides the echo for the whole of that turn.
const remoteUserEcho = $derived.by(() => {
const echo = aiChatManager.remoteUserMessage
if (!echo || !aiChatManager.runHeldElsewhere) return undefined
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role !== 'user') continue
const held = messages[i].content.trim()
const already = echo.length >= RUN_PROMPT_ECHO_MAX ? held.startsWith(echo) : held === echo
return already ? undefined : echo
}
return echo
const at = aiChatManager.remoteUserMessageAt
return at !== undefined && messages.length > at ? undefined : echo
})
// Get app context for display when in APP mode
@@ -595,6 +595,10 @@ export class AIChatManager {
// until its turn ends, and this is only what to draw in the meantime. The
// runtime clears it as part of the catch-up that brings in the real message.
remoteUserMessage = $state<string | undefined>(undefined)
// Where that prompt sits in the driving tab's transcript, so this tab can tell
// "I already hold this message" from "I hold a different one that reads the
// same" without comparing text.
remoteUserMessageAt = $state<number | undefined>(undefined)
autonomyMode = $state<AIAutonomyMode>(getPersistedAutonomyMode())
// Set by AI sessions. Enables the session-only preview tools and gates plan mode, which
// needs the preview pane; the global side-panel chat leaves it false. Reactive because
@@ -659,13 +659,6 @@ export type DisplayMessage =
// is paused on the user. Drives the question card's interactivity, the
// "waiting for user" indicator, and routing a composer send to the answer —
// keep those in sync by going through this single predicate.
/** Longest prompt a driving tab echoes to the tabs watching its run, and the
* ceiling that keeps the run-status message bounded — it is the one field whose
* length a user sets. Lives here rather than beside the sender so the receiver
* can tell a truncated echo from a whole one without importing from `sessions`,
* which only imports in the other direction. */
export const RUN_PROMPT_ECHO_MAX = 2000
export function isActiveUserQuestion(message: DisplayMessage | undefined): boolean {
return Boolean(
message &&
@@ -91,11 +91,7 @@ import type {
} from '$lib/components/raw_apps/rawAppDom'
import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib'
import { sendUserToast } from '$lib/toast'
import {
pendingUserAction,
RUN_PROMPT_ECHO_MAX,
type DisplayMessage
} from '$lib/components/copilot/chat/shared'
import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
import {
broadcastRunStatus,
@@ -1015,6 +1011,10 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
// ticks and is not reaped as a closed tab.
const statusTimers = new Map<string, ReturnType<typeof setInterval>>()
/** Longest prompt echoed to the other tabs, and the ceiling that keeps the run
* status bounded: it is the one field of that message whose length a user sets. */
const RUN_PROMPT_ECHO_MAX = 2000
/** The prompt this run is working on: the last thing the user said, searching no
* further back than `from`. Read off the driver's rendered transcript rather
* than the request, so it is the same text the driving tab has on screen. */
@@ -1060,6 +1060,7 @@ function postRunStatus(sessionId: string): void {
blockedOnUser: pendingUserAction(m.displayMessages) !== undefined,
loadingLabel: m.loadingLabel,
userMessage: currentRunPrompt(sessionId, m.displayMessages),
userMessageAt: runPrompts.get(sessionId)?.from,
planModeActive: m.planModeActive
})
}
@@ -1100,6 +1101,7 @@ function applyRunStatus(msg: RunStatusMsg): void {
m.loading = msg.loading
m.compacting = msg.compacting
m.remoteUserMessage = msg.userMessage
m.remoteUserMessageAt = msg.userMessageAt
m.loadingLabel = msg.blockedOnUser ? 'Waiting for your answer in the other tab' : msg.loadingLabel
}
@@ -1126,6 +1128,7 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro
m.loadingLabel = undefined
m.compacting = false
m.remoteUserMessage = undefined
m.remoteUserMessageAt = undefined
const id = chatId || m.historyManager.getCurrentChatId()
if (!id) {
caughtUp = true
@@ -511,36 +511,6 @@ let remoteReadSeq = 0
// would roll the record back on the next write. Reading instead always lands on
// what the shared store actually holds. In-memory only — re-persisting here
// would echo straight back out through the row funnel.
/** Take on what another tab stored, into the record object this tab already
* holds rather than in place of it.
*
* Identity matters: the debounced writers capture the object they were called
* on (`setSessionDraftPrompt` flushes 400ms later through `persistTouched(s)`),
* so swapping the array slot leaves that flush writing a record this list no
* longer contains — putting back the state it held before the other tab wrote.
*
* Preview tabs keep their live overrides. `friendlyLabel` / `friendlyPath` /
* `editorNamed` are stamped by the open editor and deliberately not persisted,
* so they are absent from every stored row; taking the row's tabs wholesale
* would drop a watching tab's breadcrumbs back to raw paths on any write from
* the other tab, a seen-watermark bump included. */
function adoptRemoteRow(held: Session, row: Session): void {
const stamped = new Map((held.previewTabs ?? []).map((t) => [t.id, t]))
Object.assign(held, row)
if (row.previewTabs) {
held.previewTabs = row.previewTabs.map((t) => {
const live = stamped.get(t.id)
if (!live) return t
return {
...t,
friendlyLabel: live.friendlyLabel,
friendlyPath: live.friendlyPath,
editorNamed: live.editorNamed
}
})
}
}
async function applyRemoteSessionPut(id: string): Promise<void> {
if (deletedSessionIds.has(id)) return
const token = ++remoteReadSeq
@@ -571,6 +541,45 @@ async function applyRemoteSessionPut(id: string): Promise<void> {
else sessionState.sessions.splice(at, 0, row)
}
/** Take on what another tab stored, into the record object this tab already
* holds rather than in place of it.
*
* Identity matters: the debounced writers capture the object they were called
* on (`setSessionDraftPrompt` flushes 400ms later through `persistTouched(s)`),
* so swapping the array slot leaves that flush writing a record this list no
* longer contains — putting back the state it held before the other tab wrote.
*
* Preview tabs keep their live overrides. `friendlyLabel` / `friendlyPath` /
* `editorNamed` are stamped by the open editor and deliberately not persisted,
* so they are absent from every stored row; taking the row's tabs wholesale
* would drop a watching tab's breadcrumbs back to raw paths on any write from
* the other tab, a seen-watermark bump included. */
export function adoptRemoteRow(held: Session, row: Session): void {
const stamped = new Map((held.previewTabs ?? []).map((t) => [t.id, t]))
// Keys the row no longer carries are removed, not left standing. This file
// clears a field by deleting it — see `applyLifecyclePatch`, and the delete of
// `archived` an unarchive performs — and `putSessionRow` stores a snapshot, so
// a dropped key is how "this is no longer set" arrives. Assigning over the
// held object alone keeps the stale value, and this tab's next write to the
// record puts it back into the store, undoing what the other tab did.
for (const k of Object.keys(held)) {
if (!(k in row)) delete (held as Record<string, unknown>)[k]
}
Object.assign(held, row)
if (row.previewTabs) {
held.previewTabs = row.previewTabs.map((t) => {
const live = stamped.get(t.id)
if (!live) return t
return {
...t,
friendlyLabel: live.friendlyLabel,
friendlyPath: live.friendlyPath,
editorNamed: live.editorNamed
}
})
}
}
// Mirror of the local delete path: tombstone so this tab's own pending writes
// (unread watermark, preview-tab flush) can't resurrect a record another tab
// removed, then drop it from the list.
@@ -8,6 +8,7 @@ import {
renameSession,
sessionInCurrentFamily,
setGeneratedSessionSummary,
adoptRemoteRow,
setSessionDraftPrompt,
sessionState,
type Session
@@ -488,3 +489,50 @@ describe('createSession — reuses an untouched draft, family-scoped', () => {
}
})
})
describe('adoptRemoteRow', () => {
it('clears what the stored row no longer carries, keeping the object identity', () => {
const held = {
id: 's1',
name: 's1',
createdAt: 1,
archived: true,
archivedByWorkspace: true
} as Session
// The other tab unarchived: this file clears a field by deleting it, so the
// row simply lacks the key. Assigning over the held object would keep the
// stale flag, and this tab's next write would put it back in the store.
const row = { id: 's1', name: 's1', createdAt: 1 } as Session
adoptRemoteRow(held, row)
expect('archived' in held).toBe(false)
expect('archivedByWorkspace' in held).toBe(false)
})
it('keeps the live editor stamps the store never holds', () => {
const held = {
id: 's1',
name: 's1',
createdAt: 1,
previewTabs: [
{ id: 't1', url: '/u', loc: '/u', friendlyLabel: 'My script', friendlyPath: 'f/a/b' }
]
} as Session
// Stored tabs carry no stamps — they are recomputed on mount — so taking the
// row's tabs wholesale drops a watching tab's labels back to raw paths on any
// write from the other tab.
const row = {
id: 's1',
name: 's1',
createdAt: 1,
previewTabs: [{ id: 't1', url: '/u', loc: '/u2' }]
} as Session
adoptRemoteRow(held, row)
expect(held.previewTabs?.[0].loc).toBe('/u2')
expect(held.previewTabs?.[0].friendlyLabel).toBe('My script')
expect(held.previewTabs?.[0].friendlyPath).toBe('f/a/b')
})
})
@@ -72,6 +72,12 @@ type RunStatusMsg = {
* wire format fixes; this is the one field a user sets the length of, and the
* bound is what keeps that true of the message as a whole. */
userMessage: string | undefined
/** Where this run's first message sits in the driver's transcript. A watching
* tab that has read that far already holds the real message and must not draw
* the echo beside it; one that has not, must. Carried because the receiver
* cannot decide it from the text: the same prompt sent twice running is a new
* message, not a duplicate of the one above it. */
userMessageAt: number | undefined
/** 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