diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 636f5baecd..c1f52375fa 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -26,7 +26,12 @@ import { fade } from 'svelte/transition' import Popover from '$lib/components/meltComponents/Popover.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' - import { pendingUserAction, pendingUserActionDetail, type DisplayMessage } from './shared' + import { + pendingUserAction, + pendingUserActionDetail, + RUN_PROMPT_ECHO_MAX, + 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' @@ -554,15 +559,21 @@ // // 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. The echo is a prefix of - // what the driver holds (it is truncated at the source), which is what makes - // `startsWith` the right test. + // echo beside it would be the same text 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. 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 - return messages[i].content.trim().startsWith(echo) ? undefined : echo + 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 }) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 0dffe17eb1..51b061336c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -30,7 +30,6 @@ import { backgroundJobCompletionNote, deriveChatJobStatus, pendingToolImagesMessage, - pendingUserAction, trimJob } from './shared' import type { @@ -3274,14 +3273,6 @@ export class AIChatManager { // the poll exactly when nobody is watching. `pending` reads it without // disturbing the animation. const streaming = this.currentReply + this.replyReveal.pending - // Parked on the user, so nothing is advancing to preserve — and a - // snapshot taken here would store the question closed as interrupted, - // which is what it means only if this tab died. Another tab that opens - // the session meanwhile reads that as a failed call while the run is in - // fact waiting. The checkpoint before this one still holds everything - // the turn did up to the question, and the question itself does not - // survive a reload either way: its resolver goes with the page. - if (pendingUserAction(this.displayMessages)) return // Write only when the turn advanced, so a parked confirmation costs // nothing and the rate follows steps taken rather than time. const shape = `${collectedMessages.length}:${this.displayMessages.length}:${streaming.length}` diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index b485be51b3..38c8b2243c 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -659,6 +659,13 @@ 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 92521b4388..0e9e5e7b5c 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -91,7 +91,11 @@ import type { } from '$lib/components/raw_apps/rawAppDom' import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib' import { sendUserToast } from '$lib/toast' -import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared' +import { + pendingUserAction, + RUN_PROMPT_ECHO_MAX, + type DisplayMessage +} from '$lib/components/copilot/chat/shared' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' import { broadcastRunStatus, @@ -1011,11 +1015,6 @@ 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. Enough for the prompts people - * actually type, and a hard ceiling on the one field of the status message - * whose length a user controls. */ -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. */ diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 6c8f0db986..928d901b92 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -511,6 +511,36 @@ 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 @@ -531,7 +561,7 @@ async function applyRemoteSessionPut(id: string): Promise { if (!row || deletedSessionIds.has(id)) return const i = sessionState.sessions.findIndex((s) => s.id === id) if (i >= 0) { - sessionState.sessions[i] = row + adoptRemoteRow(sessionState.sessions[i], row) return } // New elsewhere: slot it in by createdAt, after any local transient drafts,