From 87afd0d191cfcc1d49b099463d6432fe03aa3265 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:20:09 +0200 Subject: [PATCH] feat: rebuild the run_script card as a tool call row, with streaming --- .../copilot/chat/AIChatManager.svelte.ts | 37 +- .../copilot/chat/ChatCollapsibleCard.svelte | 5 + .../copilot/chat/RunArgsFormDisplay.svelte | 108 ++-- .../copilot/chat/RunScriptCard.svelte | 472 +++++++++--------- .../copilot/chat/ToolContentDisplay.svelte | 15 +- .../copilot/chat/ToolPreviewCard.svelte | 12 +- .../components/copilot/chat/shared.test.ts | 13 +- .../src/lib/components/copilot/chat/shared.ts | 91 +++- .../lib/components/runs/JobStatusIcon.svelte | 60 ++- .../sessions/sessionRuntime.svelte.ts | 14 +- 10 files changed, 493 insertions(+), 334 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index d2659ebdda..3108e3198f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -30,6 +30,7 @@ import { type ChatJobStatus, completedJobToolStatus, backgroundJobCompletionNote, + createJobUpdateReader, deriveChatJobStatus, pendingToolImagesMessage, trimJob @@ -502,6 +503,10 @@ export class AIChatManager { // Consecutive getJob failures per background job, so a vanished/404 job can be // drained instead of polled forever. Ephemeral, keyed by jobId. #jobPollFailures = new Map() + // Incremental log/result-stream readers, keyed by jobId. A job that detaches out of + // the inline wait keeps streaming into its card through these; each holds its own + // offsets, so one created after a reload refetches from the start. + #jobUpdateReaders = new Map>() /** Opens a run in the sessions preview pane. Set by the session runtime; * undefined in the global side-panel chat, where the tray falls back to opening * the run in a new browser tab. */ @@ -519,12 +524,11 @@ export class AIChatManager { workspace: string label: string }) => void - /** Whether the panel holds this call: its pending form, and with a `jobId`, the run that - * form started. Answered off the session's tab list, so it stays true while the user is on - * another tab, and per call rather than "the open one". Read from a `$derived` — the reader - * subscribes to the tab list through the call. The card collapses on it, which is what keeps - * one form mounted per call and the panel from repeating what the card shows. */ - isCallInPreview?: (a: { toolCallId: string; jobId?: string }) => boolean + /** Whether the panel holds this call's pending form. Answered off the session's tab list, + * so it stays true while the user is on another tab, and per call rather than "the open + * one". Read from a `$derived` — the reader subscribes to the tab list through the call. + * The card hides its form on it, which is what keeps exactly one mounted per call. */ + isRunFormInPreview?: (toolCallId: string) => boolean openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void closeArtifact?: (artifactId: string) => void #loading = $state(false) @@ -886,8 +890,7 @@ export class AIChatManager { ] // The panel was holding this call's form and the call now has a job: the tab follows // the call rather than being left on a form that has already run. - // No jobId here on purpose: the question is whether the form is still the tab. - if (this.isCallInPreview?.({ toolCallId: init.toolCallId })) { + if (this.isRunFormInPreview?.(init.toolCallId)) { this.showRunInPlaceOfForm?.({ toolCallId: init.toolCallId, jobId: init.jobId, @@ -1008,6 +1011,21 @@ export class AIChatManager { let anyTerminal = false for (const job of pending) { try { + // Its own output first, so a run that detached out of the inline wait keeps + // filling its card. `getJob` alone would freeze a streamed result until the + // job landed — the partial is only on the updates endpoint. + let reader = this.#jobUpdateReaders.get(job.jobId) + if (!reader) { + reader = createJobUpdateReader(job.jobId, job.workspace) + this.#jobUpdateReaders.set(job.jobId, reader) + } + const update = await reader.poll() + if (gen !== this.#jobPollGeneration) return + this.applyToolStatus(job.toolCallId, { + logs: update.logs || undefined, + resultStream: update.resultStream || undefined + }) + const fetched = await JobService.getJob({ workspace: job.workspace, id: job.jobId, @@ -1021,6 +1039,7 @@ export class AIChatManager { this.#jobPollFailures.delete(job.jobId) if (fetched.type === 'CompletedJob') { anyTerminal = true + this.#jobUpdateReaders.delete(job.jobId) this.#onBackgroundJobComplete(job, fetched as CompletedJob) } else { // Store the derived status and the trimmed Job together so the tray @@ -1042,6 +1061,7 @@ export class AIChatManager { this.#jobPollFailures.set(job.jobId, failures) if (httpStatus === 404 || failures >= 5) { this.#jobPollFailures.delete(job.jobId) + this.#jobUpdateReaders.delete(job.jobId) // Vanished (404) or unreachable after repeated polls. Mark it failed WITH // a snapshot + tool-card patch (mirroring #onBackgroundJobComplete) so // neither the tray badge nor the launching tool card stays frozen on @@ -1200,6 +1220,7 @@ export class AIChatManager { this.#jobPollGeneration++ clearTimeout(this.#autoResumeRetry) this.#autoResumeRetry = undefined + this.#jobUpdateReaders.clear() this.backgroundJobs = [] this.pendingJobNotes = [] } diff --git a/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte b/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte index f92e347da9..febe9cf146 100644 --- a/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte @@ -13,6 +13,9 @@ toggleable?: boolean // Sweeps a highlight across the label while the row is in progress. shimmer?: boolean + // Ahead of the label, inside the toggle button: a status that reads as part of the + // row rather than as another control, leaving the chevron next to the label it opens. + headerLeft?: Snippet // Pinned to the right of the header row, outside the toggle button. headerRight?: Snippet // Always-visible content between the header and the expandable body. @@ -30,6 +33,7 @@ onToggle, toggleable = true, shimmer = false, + headerLeft, headerRight, belowHeader, children, @@ -62,6 +66,7 @@ onclick={onToggle} disabled={!toggleable} > + {@render headerLeft?.()} {#if shimmer} {@render labelText(false)} diff --git a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte index c900b4c4f2..f5a3845224 100644 --- a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte @@ -57,6 +57,13 @@ const fades = scrollFades() const { container: fadeContainer, content: fadeContent, measure: measureFades } = fades + // The two hosts stand on different surfaces — the chat card on the tool call's own, the + // preview tab on the raised one — and a fade has to end in the colour behind it. + const fadeTo = $derived( + layout === 'pane' + ? 'bg-gradient-to-t from-surface-tertiary via-surface-tertiary/60 to-transparent' + : 'bg-gradient-to-t from-surface via-surface/60 to-transparent' + ) async function run() { if (submitting || !isValid) return @@ -106,57 +113,52 @@ on this phase only, which is why a settled card drops it with the form. -->
-
- - {#if fades.top} -
- {/if} -
- {#if hasArgs} - - - {:else} -

This script takes no arguments.

- {/if} + + {:else} +

This script takes no arguments.

+ {/if} +
+ {#if fades.bottom}
{/if}
@@ -192,19 +194,12 @@

{PLAN_MODE_MESSAGES.runFormRefused}

{/if} - -
- + +
+
diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index 1e21ae37ea..bd36a8e422 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -1,14 +1,20 @@ + +{#snippet status()} + {#if !pending} + + + {statusTime} + + {/if} +{/snippet} + + +{#snippet previewChip()} + +{/snippet} + -
(toggled = { id: message.tool_call_id, open: !expanded })} + headerLeft={status} + headerRight={previewTarget ? previewChip : undefined} + class="scroll-mb-8" + contentClass="p-0 overflow-hidden" > -
- - -
-

- Run {runForm.summary || runForm.path} -

- {#if runForm.summary} -

{runForm.path}

- {/if} -
- {#if !pending || previewTarget} -
- - {#if pending} - - {:else if running} - - - {elapsed} - - {:else if failed} - - - Failed - - {:else if canceled && ran} - - - - {duration || 'Cancelled'} - - {:else if canceled} - - - - Not run - - {:else} - - - {duration || 'Done'} - - {/if} - {#if !pending && !inPreview} - -
- {/if} -
- - {#if inPreview} - -
- {pending - ? 'The parameters are open in the preview panel.' - : 'This run is open in the preview panel.'} + {#if formInPreview} +
+ These inputs are open in the preview panel.
{:else if pending} {:else} - -
- {#if !jsonView} - (userTab = e.detail)} - class="border-t border-border-light px-3" - wrapperClass="shrink-0" - > + +
+ + + (userTab = e.detail)} + class="h-8 px-3" + wrapperClass="shrink-0" + > + {#if !jsonView} {#each tabs as tab (tab.value)} +
- {#if jsonView} -
- - - -
- {:else if activeTab === 'input'} - +
+ {#if jsonView} +
+ + + + +
+ {:else if activeTab === 'input'} + - - {:else if activeTab === 'logs'} - {#if logs.trim()} - {#if logs.length >= MAX_LOG_LENGTH} -

- Tail of the logs, the last {MAX_LOG_LENGTH} characters. -

+ + {:else if activeTab === 'logs'} + {#if logs.trim()} + {#if logs.length >= MAX_LOG_LENGTH} +

+ Tail of the logs, the last {MAX_LOG_LENGTH} characters. +

+ {/if} +
{logs}
+ {:else} +

No logs yet.

{/if} -
{logs}
+ + streaming +
+ {/if} + {:else if failed} +
{message.error}
- {:else} -

No logs yet.

- {/if} - {#if running} -
- - streaming -
- {/if} - {:else if failed} -
{message.error}
- {:else if resultValue !== undefined} - + + {:else if resultValue !== undefined} + - - {:else if canceled} - -
- -

{cancelReason}

- {#if !ran} -

- The parameters it would have run with are on the Parameters tab. -

- {/if} -
- {:else} -

This run returned no result.

- {/if} + + {:else if canceled} + +
+ +

{cancelReason}

+ {#if !ran} +

+ The inputs it would have run with are on the Inputs tab. +

+ {/if} +
+ {:else} +

This run returned no result.

+ {/if} +
+ + + {#if fades.bottom && fadeBody} +
+ {#if activeTab === 'logs'} +
+ {/if} + {/if}
- -
- {footer} - {#if running && chatJob} - - + {#if running && chatJob} + +
- {/if} -
+
+ {/if} {/if} -
+ diff --git a/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte index a1578cd815..5b7c560a19 100644 --- a/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte @@ -13,6 +13,9 @@ streaming?: boolean toolName?: string showFade?: boolean + /** Open on the end of the content instead of its start, and stay there as it grows. + * For logs, whose last lines are the ones being looked for. */ + tail?: boolean } let { @@ -24,7 +27,8 @@ showWhileLoading = true, streaming = false, toolName, - showFade = false + showFade = false, + tail = false }: Props = $props() let copied = $state(false) @@ -82,6 +86,12 @@ // max-h-28 as well as the first paint. const fades = scrollFades() const { container: fadeContainer, content: fadeContent, measure: measureFades } = fades + + let scroller = $state() + $effect(() => { + void content + if (tail && scroller) scroller.scrollTop = scroller.scrollHeight + }) {#if showWhileLoading || (!loading && hasContent) || streaming} @@ -117,6 +127,7 @@ {:else if hasContent}
{#if showFade && fades.bottom}
{/if}
diff --git a/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte index 81d00fd8e4..fd3c8debc8 100644 --- a/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte @@ -8,15 +8,23 @@ interface Props { card: { kind: PreviewCardKind; path: string } + /** Opens something other than the item's own preview — the run card opens the call + * it owns, which is a form before it is a run. */ + onOpen?: () => void + title?: string } - let { card }: Props = $props() + let { card, onOpen, title }: Props = $props() const kindLabel = $derived(card.kind === 'raw_app' ? 'app' : card.kind) let opening = $state(false) async function open() { if (opening) return + if (onOpen) { + onOpen() + return + } opening = true try { await runToolDisplayAction(openItemPreviewAction(card.kind, card.path)) @@ -30,7 +38,7 @@ variant="default" unifiedSize="2xs" disabled={opening} - title="Open {kindLabel} preview: {card.path}" + title={title ?? `Open ${kindLabel} preview: ${card.path}`} onClick={open} startIcon={{ icon: RowIcon as unknown as IconType, props: { kind: card.kind, size: 12 } }} endIcon={{ icon: PanelRight }} diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 2dc7114757..095855645e 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -38,7 +38,7 @@ vi.mock('$lib/components/flows/flowTree', () => ({ vi.mock('$lib/gen', () => ({ ScriptService: {}, FlowService: {}, - JobService: { getJob: vi.fn() }, + JobService: { getJob: vi.fn(), getJobUpdates: vi.fn() }, ScheduleService: { previewSchedule: vi.fn(), createSchedule: vi.fn() @@ -1406,6 +1406,9 @@ describe('pollJobCompletion detach', () => { const getJob = vi.mocked(JobService.getJob) getJob.mockReset() getJob.mockResolvedValue({ type: 'QueuedJob', running: true } as any) + const getJobUpdates = vi.mocked(JobService.getJobUpdates) + getJobUpdates.mockReset() + getJobUpdates.mockResolvedValue({ running: true, completed: false } as any) const cbs = makeCallbacks() // detachAfterMs 2000 → 2 polls at 1s each, then detach. @@ -1433,6 +1436,11 @@ describe('pollJobCompletion detach', () => { getJob.mockReset() const completed = { type: 'CompletedJob', success: true, result: 42 } getJob.mockResolvedValue(completed as any) + // The updates endpoint is what says the job landed; the whole job is then + // fetched once, with its logs. + const getJobUpdates = vi.mocked(JobService.getJobUpdates) + getJobUpdates.mockReset() + getJobUpdates.mockResolvedValue({ completed: true } as any) const cbs = makeCallbacks() const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any, { detachAfterMs: 15000 }) @@ -1452,6 +1460,9 @@ describe('pollJobCompletion detach', () => { const getJob = vi.mocked(JobService.getJob) getJob.mockReset() getJob.mockResolvedValue({ type: 'QueuedJob', running: true } as any) + const getJobUpdates = vi.mocked(JobService.getJobUpdates) + getJobUpdates.mockReset() + getJobUpdates.mockResolvedValue({ running: true, completed: false } as any) const cbs = makeCallbacks() const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index feeed4e24a..958518b55b 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -608,6 +608,9 @@ export type ToolDisplayMessage = { content: string parameters?: any result?: any + /** What the job has streamed of its result so far, while it is still running. + * Cleared when the job lands: `result` is then the whole of it. */ + resultStream?: string logs?: string isLoading?: boolean /** Arguments fully streamed but execution not started (see queuedToolStatus). */ @@ -1627,6 +1630,42 @@ export type BackgroundJobFormatter = (job: CompletedJob) => { card: Partial } +/** Reads a running job's output incrementally through `getJobUpdates`, which is the + * only endpoint carrying `new_result_stream`: `getJob` returns logs but never the + * partial result, so a script that streams would show nothing until it landed. Both + * the inline wait and the background poller drive one of these, so a run that detaches + * keeps streaming; each reader accumulates its own copy, so a poller that starts over + * (after a reload) refetches from offset 0 rather than appending to what it cannot see. */ +export function createJobUpdateReader(jobId: string, workspace: string) { + let logs = '' + let resultStream = '' + let logOffset = 0 + let streamOffset = 0 + let started = false + return { + async poll(): Promise<{ completed: boolean; logs: string; resultStream: string }> { + const update = await JobService.getJobUpdates({ + workspace, + id: jobId, + running: started, + logOffset, + streamOffset + }) + started ||= update.running ?? false + // Both kept as a tail: the offsets come from the server, so dropping the head + // costs nothing here, and neither is the record of the run — the logs are on the + // job, and a streamed partial is replaced by the result the moment it lands. + if (update.new_logs) logs = (logs + update.new_logs).slice(-MAX_LOG_LENGTH) + if (update.new_result_stream) { + resultStream = (resultStream + update.new_result_stream).slice(-MAX_LOG_LENGTH) + } + if (update.log_offset) logOffset = update.log_offset + if (update.stream_offset) streamOffset = update.stream_offset + return { completed: update.completed ?? false, logs, resultStream } + } + } +} + // Common job polling function. // // Two modes, selected by whether `detachAfterMs` is provided: @@ -1646,35 +1685,51 @@ export async function pollJobCompletion( const maxAttempts = detachEnabled ? Math.ceil((options?.detachAfterMs ?? 0) / 1000) : 60 let attempts = 0 let job: CompletedJob | null = null + const reader = createJobUpdateReader(jobId, workspace) while (attempts < maxAttempts) { await new Promise((resolve) => setTimeout(resolve, 1000)) attempts++ try { + const update = await reader.poll() + // The tray's snapshot is trimmed of logs (it is persisted), so the card is the + // only place a running job's output can land. Cards that hide their logs while + // loading are unaffected; the run card follows them line by line. + toolCallbacks.setToolStatus(toolId, { + logs: formatLogs(update.logs), + resultStream: update.resultStream || undefined + }) + + if (update.completed) { + // Fetched whole rather than assembled from the ticks: the reader stops at + // whatever the last one saw, and the tail written between then and the job + // landing is only on the job itself. + const completed = await JobService.getJob({ + workspace: workspace, + id: jobId, + noLogs: false, + noCode: true + }) + if (completed.type === 'CompletedJob') { + job = completed + break + } + } + + // Keeps the tray's status + Job snapshot fresh during the inline wait. Its logs + // are skipped because the reader above already has them; the badge needs the real + // Job to tell running from suspended or scheduled, which the updates do not say. const fetchedJob = await JobService.getJob({ workspace: workspace, id: jobId, - noLogs: false, + noLogs: true, noCode: true }) - - if (fetchedJob.type === 'CompletedJob') { - job = fetchedJob - break - } - // Keep the tray's status + Job snapshot fresh during the inline wait. toolCallbacks.onJobStatus?.(jobId, { status: deriveChatJobStatus(fetchedJob), job: trimJob(fetchedJob) }) - // The tray's snapshot is trimmed of logs (it is persisted), so the card is the - // only place a running job's output can land. Cards that hide their logs while - // loading are unaffected; the run card follows them line by line. - const streamed = formatLogs(fetchedJob.logs) - if (streamed) { - toolCallbacks.setToolStatus(toolId, { logs: streamed }) - } } catch (error) { if (!detachEnabled && attempts >= maxAttempts) { throw error @@ -1790,13 +1845,16 @@ export function completedJobToolStatus(job: CompletedJob): Partial { content: `${contextName} ${actionNoun} ${job.success ? 'completed successfully' : 'failed'}`, result: formatResult(job.result), logs: formatLogs(job.logs), + // The partial is the result now, so the card reads it off `result` alone and the + // transcript stops carrying a second copy of a streamed answer. + resultStream: undefined, ...(job.success ? {} : { error: getErrorMessage(job.result) }) }) diff --git a/frontend/src/lib/components/runs/JobStatusIcon.svelte b/frontend/src/lib/components/runs/JobStatusIcon.svelte index 5f93f8b6dd..0a99530609 100644 --- a/frontend/src/lib/components/runs/JobStatusIcon.svelte +++ b/frontend/src/lib/components/runs/JobStatusIcon.svelte @@ -18,28 +18,43 @@ job: Job isExternal?: boolean roundedFull?: boolean + /** Icon size in px, and the padding around it. Defaults are the runs page's; the chat's + * tool rows ask for a smaller one, since a 30px badge would set the height of a row of + * 11px text. */ + size?: number + badgeClass?: string } - let { job, isExternal = false, roundedFull = false }: Props = $props() + let { + job, + isExternal = false, + roundedFull = false, + size = 14, + badgeClass = undefined + }: Props = $props()
{#if isExternal} - - + + {:else if job.canceled && 'success' in job} - - + + {:else if 'success' in job && job.success} {#if job.is_skipped} - - + + {:else} - - + + {/if} {:else if 'success' in job && job.resolved} @@ -47,29 +62,34 @@ color="orange" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'} + class={badgeClass} title="Failed, marked as resolved" > - + {:else if 'success' in job} - - + + {:else if 'running' in job && job.running && job.suspend} - - + + {:else if 'running' in job && job.running} - - + + {:else if job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for)} - - + + {:else} - - + + {/if}
diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index b2a6722fe5..4ebdb2b293 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -530,17 +530,11 @@ function createRuntime(session: Session): SessionRuntime { previewTabs.retargetRunForm(toolCallId, `${base}/run/${jobId}?workspace=${workspace}`) } // Read off the tab list rather than the slot's lifecycle: a tab the user has switched - // away from is unmounted but still open, and the card must stay collapsed until it is - // closed. A resolver, like activePreviewResolver: the reader's own $derived subscribes + // away from is unmounted but still open, and the card must keep its form hidden until it + // is closed. A resolver, like activePreviewResolver: the reader's own $derived subscribes // to `tabs` through it, and the runtime is not inside an effect root to push from. - manager.isCallInPreview = ({ toolCallId, jobId }) => - previewTabs.tabs.some((t) => { - const form = parseRunFormRoute(t.url) - if (form) return form.toolCallId === toolCallId - // The run page of this call's own job, whether the tab got there by following the - // form or was opened straight onto it. - return !!jobId && t.url.startsWith(`${base}/run/${jobId}`) - }) + manager.isRunFormInPreview = (toolCallId) => + previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId) manager.openArtifact = (id, name, version) => { previewTabs.open({ type: 'artifact', id, name, version })