mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
7455daf881
commit
bed7d9a478
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user