diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index c1f52375fa..88f9910603 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -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 diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 51b061336c..06511f105f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -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(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(undefined) autonomyMode = $state(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 diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 38c8b2243c..b485be51b3 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -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 && diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 0e9e5e7b5c..b712be01e2 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -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>() +/** 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 diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 928d901b92..02a47b4066 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -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 { if (deletedSessionIds.has(id)) return const token = ++remoteReadSeq @@ -571,6 +541,45 @@ async function applyRemoteSessionPut(id: string): Promise { 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)[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. diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts index 7732c44746..7b4d53057e 100644 --- a/frontend/src/lib/components/sessions/sessionState.test.ts +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -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') + }) +}) diff --git a/frontend/src/lib/components/sessions/sessionSync.svelte.ts b/frontend/src/lib/components/sessions/sessionSync.svelte.ts index 2c21bacec8..bfb95986ef 100644 --- a/frontend/src/lib/components/sessions/sessionSync.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSync.svelte.ts @@ -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