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}
+
: '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