fix(ai-sessions): show a run in another tab instead of a blank pane

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-31 17:29:16 +02:00
co-authored by Claude Opus 5
parent 7455daf881
commit bed7d9a478
4 changed files with 134 additions and 3 deletions
@@ -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. -->
</div>
</div>
{/if}
<!-- The prompt of a run this tab is only watching. Styled as the user bubble it
stands in for, so the transcript reads the same once the real one lands. -->
{#snippet remoteUserEchoBubble(trailingSpace: string)}
{#if remoteUserEcho}
<div class={twMerge('text-sm py-1 px-2', trailingSpace)}>
<div
class="text-xs px-3 py-2 w-fit max-w-[min(32rem,100%)] bg-surface-accent-selected text-accent rounded-lg break-words whitespace-pre-wrap"
>{remoteUserEcho}</div
>
</div>
{/if}
{/snippet}
{#if messages.length === 0}
{#if emptyHint}
{#if aiChatManager.runHeldElsewhere}
<!-- The run indicator below rides the transcript, and a tab that opened
before the driver's first save has no transcript to put it on: the
conversation arrives only when the turn ends. Without this the
session reads as idle for the whole turn. Laid out as the message
column so the echoed prompt sits where its real bubble will. -->
<div class="flex-1 min-h-0 overflow-y-auto pt-2">
<div
class={wideLayout
? 'w-full max-w-3xl mx-auto px-7 flex flex-col'
: 'w-full max-w-2xl mx-auto px-3 flex flex-col'}
>
{@render remoteUserEchoBubble('mb-2')}
<div class="self-start ml-2">
<ChatTypingIndicator
loading
label={aiChatManager.loadingLabel ?? 'Running in another tab'}
/>
</div>
</div>
</div>
{:else if emptyHint}
{@render emptyHint()}
{:else}
<span class="text-2xs text-gray-500 dark:text-gray-400 text-center px-2 my-2"
@@ -723,15 +777,22 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
: 'w-full max-w-2xl mx-auto px-3 flex flex-col pb-2'}
bind:clientHeight={height}
>
<!-- `isLast` reserves the trailing space the sticky indicator is pulled
up over. The echoed prompt below is what the indicator sits on when
it is showing, so it takes that role and the real last message keeps
normal spacing. -->
{#each messages as message, messageIndex (messageIndex)}
<AIChatMessage
{message}
{messageIndex}
{availableContext}
bind:editingMessageIndex
isLast={messageIndex === messages.length - 1}
isLast={messageIndex === messages.length - 1 && !remoteUserEcho}
/>
{/each}
<!-- Same trailing space the last message reserves: the indicator below
is sticky and pulled up over it, so a bubble without it is covered. -->
{@render remoteUserEchoBubble('mb-12')}
{#if showTypingIndicator}
<div
class={twMerge(
@@ -30,6 +30,7 @@ import {
backgroundJobCompletionNote,
deriveChatJobStatus,
pendingToolImagesMessage,
pendingUserAction,
trimJob
} from './shared'
import type {
@@ -589,6 +590,12 @@ export class AIChatManager {
// before the request goes out. Takes precedence over the compacting/thinking
// labels while set; the hook clears it back to undefined when done.
loadingLabel = $state<string | undefined>(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<string | 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
@@ -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}`
@@ -1011,6 +1011,42 @@ 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. 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<string, { from: number; prompt?: string }>()
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
@@ -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