From bed7d9a478e918875c8115814af42b1ff6782e7d Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 31 Aug 2026 17:29:16 +0200 Subject: [PATCH] fix(ai-sessions): show a run in another tab instead of a blank pane Co-Authored-By: Claude Opus 5 --- .../copilot/chat/AIChatDisplay.svelte | 65 ++++++++++++++++++- .../copilot/chat/AIChatManager.svelte.ts | 15 +++++ .../sessions/sessionRuntime.svelte.ts | 48 +++++++++++++- .../components/sessions/sessionSync.svelte.ts | 9 +++ 4 files changed, 134 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 3a807a2a18..636f5baecd 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -547,6 +547,26 @@ // deliver to nobody. const composerLocked = $derived(aiChatManager.runHeldElsewhere) + // The prompt the other tab's run is working on, drawn beside the indicator so + // a watching tab says what is running and not only that something is. Nothing + // 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. The echo is a prefix of + // what the driver holds (it is truncated at the source), which is what makes + // `startsWith` the right test. + 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 + } + return echo + }) + // Get app context for display when in APP mode const appContext = $derived.by((): SelectedContext | undefined => { if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) { @@ -699,8 +719,42 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/if} + + {#snippet remoteUserEchoBubble(trailingSpace: string)} + {#if remoteUserEcho} +
+
{remoteUserEcho}
+
+ {/if} + {/snippet} + {#if messages.length === 0} - {#if emptyHint} + {#if aiChatManager.runHeldElsewhere} + +
+
+ {@render remoteUserEchoBubble('mb-2')} +
+ +
+
+
+ {:else if emptyHint} {@render emptyHint()} {:else} : 'w-full max-w-2xl mx-auto px-3 flex flex-col pb-2'} bind:clientHeight={height} > + {#each messages as message, messageIndex (messageIndex)} {/each} + + {@render remoteUserEchoBubble('mb-12')} {#if showTypingIndicator}
(undefined) + // The prompt a turn running in another tab is working on, for display beside + // the indicator while this tab has no transcript of that turn. Never filed + // into `displayMessages` and never saved: the record is the driving tab's + // 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) 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 @@ -3267,6 +3274,14 @@ 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/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 41d1f73a1f..92521b4388 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -1011,6 +1011,42 @@ 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. */ +function runPromptEcho(messages: DisplayMessage[], from: number): string | undefined { + for (let i = messages.length - 1; i >= from; i--) { + if (messages[i].role !== 'user') continue + const text = messages[i].content.trim() + // An attachment-only send has no text to echo; the indicator stands alone. + return text ? text.slice(0, RUN_PROMPT_ECHO_MAX) : undefined + } + return undefined +} + +/** The prompt each running turn is working on, pinned for the life of the run. + * + * Not read fresh each tick, and searched only past where the transcript stood + * when the run began. The first status goes out the moment the guard is + * entered, which is before the send has appended the user message, so the + * transcript still ends with the PREVIOUS turn's prompt then — echoing whatever + * is last would show a watching tab that one and then swap it for the real one. + * By position rather than by text, so sending the same prompt twice running + * still echoes the second one. */ +const runPrompts = new Map() + +function currentRunPrompt(sessionId: string, messages: DisplayMessage[]): string | undefined { + const entry = runPrompts.get(sessionId) + if (!entry) return undefined + if (entry.prompt === undefined) entry.prompt = runPromptEcho(messages, entry.from) + return entry.prompt +} + function postRunStatus(sessionId: string): void { const runtime = runtimes.get(sessionId) if (!runtime) return @@ -1024,12 +1060,18 @@ function postRunStatus(sessionId: string): void { compacting: m.compacting, blockedOnUser: pendingUserAction(m.displayMessages) !== undefined, loadingLabel: m.loadingLabel, + userMessage: currentRunPrompt(sessionId, m.displayMessages), planModeActive: m.planModeActive }) } function startRunStatus(sessionId: string): void { stopRunStatus(sessionId) + // Taken before the first post, while the transcript still ends with the turn + // this run follows: this run's prompt is whatever lands past that point. + runPrompts.set(sessionId, { + from: runtimes.get(sessionId)?.manager.displayMessages.length ?? 0 + }) // Posted immediately: this first message is the "a run started here" signal, // which is what locks the other tabs' composers. postRunStatus(sessionId) @@ -1040,6 +1082,7 @@ function startRunStatus(sessionId: string): void { } function stopRunStatus(sessionId: string): void { + runPrompts.delete(sessionId) const timer = statusTimers.get(sessionId) if (!timer) return clearInterval(timer) @@ -1057,6 +1100,7 @@ function applyRunStatus(msg: RunStatusMsg): void { const m = runtime.manager m.loading = msg.loading m.compacting = msg.compacting + m.remoteUserMessage = msg.userMessage m.loadingLabel = msg.blockedOnUser ? 'Waiting for your answer in the other tab' : msg.loadingLabel } @@ -1077,10 +1121,12 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro const m = runtime.manager // `loading` has to go first: loadPastChat refuses to run while the manager // looks busy, and this one is the driver's, not a turn of our own. These - // three are exactly what applyRunStatus sets. + // four are exactly what applyRunStatus sets — the echoed prompt among them, + // since the re-read below brings in the real message it stood in for. m.loading = false m.loadingLabel = undefined m.compacting = false + m.remoteUserMessage = undefined const id = chatId || m.historyManager.getCurrentChatId() if (!id) { caughtUp = true diff --git a/frontend/src/lib/components/sessions/sessionSync.svelte.ts b/frontend/src/lib/components/sessions/sessionSync.svelte.ts index e0e183644f..2c21bacec8 100644 --- a/frontend/src/lib/components/sessions/sessionSync.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSync.svelte.ts @@ -63,6 +63,15 @@ type RunStatusMsg = { * says where to answer it rather than implying work is in progress. */ blockedOnUser: boolean loadingLabel: string | undefined + /** The prompt the driver's turn is running on, shown beside the indicator so a + * watching tab says what is being worked on rather than only that something + * is. Display-only: the receiver renders it and never files it into a + * transcript, so it cannot reach the shared record. + * + * Truncated at the source. Every other field here is a scalar whose size the + * 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 /** 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