feat: rebuild the run_script card as a tool call row, with streaming

This commit is contained in:
AlexRV12
2026-09-03 16:31:55 +02:00
parent 330dab87a9
commit 87afd0d191
10 changed files with 493 additions and 334 deletions
@@ -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<string, number>()
// 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<string, ReturnType<typeof createJobUpdateReader>>()
/** 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<boolean>(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 = []
}
@@ -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}
<span class="shimmer inline-flex items-center min-w-0">
{@render labelText(false)}
@@ -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. -->
<div
bind:this={cardNode}
class={twMerge('flex flex-col', layout === 'pane' ? 'h-full min-h-0' : '')}
class={twMerge('flex flex-col', layout === 'pane' ? 'h-full min-h-0' : 'pt-3')}
data-chat-keyboard-scope="run-args-form"
>
<!-- Only the fields scroll. A script with many arguments would otherwise grow a card
taller than the pane, pushing the Run button and the lines naming what the form
dropped — a secret it opened empty among them — below the fold. `both-edges` reserves
the gutter on both sides, so the fields stay centred rather than drifting left of it. -->
<div
use:fadeContainer
onscroll={measureFades}
class={twMerge(
'overflow-y-auto px-3',
layout === 'pane' ? 'min-h-0 flex-1' : 'max-h-[min(28rem,50vh)]'
)}
style="scrollbar-gutter: stable both-edges;"
>
<!-- Fades what scrolls under the heading and over the actions instead of cutting it, as
ArtifactViewer does under its own header. The negative margins cancel the flow height
so each overlays the fields rather than pushing them, which is also why toggling one
moves nothing. The scroller carries no vertical padding: sticky cannot enter it, so a
padded box would fade short of its own edges and leave a band of content sharp. The
heading and the actions pad this gap instead. -->
{#if fades.top}
<div class="sticky top-0 z-10 -mb-3 h-3 bg-gradient-to-b from-surface-tertiary to-transparent"
></div>
{/if}
<div use:fadeContent>
{#if hasArgs}
<!-- The one thing here that runs before Run: a `dynselect-`/`dynmultiselect-`
<div class={twMerge('relative flex flex-col', layout === 'pane' ? 'min-h-0 flex-1' : '')}>
<div
use:fadeContainer
onscroll={measureFades}
class={twMerge(
'overflow-y-auto px-3',
layout === 'pane' ? 'min-h-0 flex-1' : 'max-h-[min(28rem,50vh)]'
)}
style="scrollbar-gutter: stable both-edges;"
>
<div use:fadeContent>
{#if hasArgs}
<!-- The one thing here that runs before Run: a `dynselect-`/`dynmultiselect-`
argument makes DynamicInput execute that entrypoint on mount to fill its options —
a real job on the deployed script, carrying the other args as proposed, and Cancel
does not undo it. Everything else waits for the user; keep it that way. -->
<SchemaForm
bind:schema={draft.schema}
helperScript={planMode
? undefined
: { source: 'deployed', path: runForm.path, runnable_kind: 'script' }}
disabled={planMode}
{workspace}
prettifyHeader
lightHeader
bind:isValid
bind:args={draft.args}
/>
{:else}
<p class="text-xs text-secondary">This script takes no arguments.</p>
{/if}
<SchemaForm
bind:schema={draft.schema}
helperScript={planMode
? undefined
: { source: 'deployed', path: runForm.path, runnable_kind: 'script' }}
disabled={planMode}
{workspace}
prettifyHeader
lightHeader
bind:isValid
bind:args={draft.args}
/>
{:else}
<p class="text-xs text-secondary">This script takes no arguments.</p>
{/if}
</div>
</div>
<!-- Only what is still below, and only while there is some: the fade the tool cards draw
under their own content, over the scroller rather than inside it. Nothing at the top —
having scrolled down is itself the knowledge that there is more up there. -->
{#if fades.bottom}
<div
class="sticky bottom-0 z-10 -mt-3 h-3 bg-gradient-to-t from-surface-tertiary to-transparent"
class={twMerge('pointer-events-none absolute inset-x-0 bottom-0 h-[min(2rem,25%)]', fadeTo)}
></div>
{/if}
</div>
@@ -192,19 +194,12 @@
<p class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.runFormRefused}</p>
{/if}
<!-- Both buttons rest while a submit is in flight: the ephemeral variables exist by
then, so cancelling would settle the call as declined on a run that is already
starting. Marked as the one part of the form Escape still stops the turn from. -->
<div class="flex items-center gap-2" data-run-form-actions>
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Play }}
disabled={!isValid || submitting || planMode}
onClick={run}
>
Run
</Button>
<!-- Reject then confirm at the end of the row, as ToolConfirmationFooter puts them:
this is a tool call being validated. Both rest while a submit is in flight, since
the ephemeral variables exist by then and cancelling would settle the call as
declined on a run already starting. Escape stops the turn from here and nowhere
else in the form. -->
<div class="flex items-center justify-end gap-2" data-run-form-actions>
<Button
variant="default"
unifiedSize="sm"
@@ -214,6 +209,15 @@
>
Cancel
</Button>
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Play }}
disabled={!isValid || submitting || planMode}
onClick={run}
>
Run
</Button>
</div>
</div>
</div>
@@ -1,14 +1,20 @@
<script lang="ts">
import { Ban, Braces, Code, Loader2, PanelRight, TimerOff } from 'lucide-svelte'
import { Ban, Loader2, TimerOff } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { Button, Tab, Tabs } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import JobStatusIcon from '$lib/components/runs/JobStatusIcon.svelte'
import type { Job } from '$lib/gen'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { displayDate, msToReadableTime } from '$lib/utils'
import { msToReadableTime } from '$lib/utils'
import JobArgs from '$lib/components/JobArgs.svelte'
import { base } from '$lib/base'
import { getAiChatManager } from './aiChatManagerContext'
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
import RunArgsFormDisplay from './RunArgsFormDisplay.svelte'
import ToolContentDisplay from './ToolContentDisplay.svelte'
import ToolPreviewCard from './ToolPreviewCard.svelte'
import { scrollFades } from './scrollFades.svelte'
import { isActiveRunForm, MAX_LOG_LENGTH, type ToolDisplayMessage } from './shared'
const aiChatManager = getAiChatManager()
@@ -54,6 +60,12 @@
)
const logs = $derived(typeof message.logs === 'string' ? message.logs : '')
const logLineCount = $derived(logs.trim() ? logs.trimEnd().split('\n').length : 0)
// What the job has streamed of its result so far. Only ever set while it runs: the
// terminal patch clears it, so a settled card reads its outcome off `result` alone.
const resultStream = $derived(
typeof message.resultStream === 'string' ? message.resultStream : ''
)
const streaming = $derived(running && resultStream.length > 0)
// The card stores its result as text (see formatResult), so read it back into a
// value DisplayResult can render: a markdown, table or image result is what the
@@ -69,6 +81,16 @@
}
})
// The row is the card's whole heading, in the tense the call is in: a run cancelled
// before it started never ran, so it is still the thing that was going to be run.
const label = $derived(
running
? `Running ${runForm.path}`
: settled && ran
? `Ran ${runForm.path}`
: `Run ${runForm.path}`
)
// Being cancelled is an outcome like any other, and it is the one the card has to say out
// loud: nothing came back, so no other tab can carry it.
const outcomeTab = $derived(
@@ -79,14 +101,13 @@
? 'This run was cancelled while the script was running.'
: 'This run was cancelled before the script started.'
)
// Streaming opens the tab early: the result is already arriving, and one that appeared
// only at the end would hide the thing the user is waiting to read.
const hasOutcome = $derived(settled || streaming)
const tabs = $derived([
// The table below keeps JobArgs' own "Input" heading, so the tab is named for what the
// chat calls them instead of repeating that word twice over.
{ value: 'input', label: 'Parameters' },
{ value: 'input', label: 'Inputs' },
...(ran ? [{ value: 'logs', label: 'Logs' }] : []),
// Only once there is an outcome: the tab appearing is how the card says the run
// landed, so it must not sit there empty while the job is still going.
...(settled ? [{ value: 'outcome', label: outcomeTab }] : [])
...(hasOutcome ? [{ value: 'outcome', label: outcomeTab }] : [])
])
// Undefined until a tab is clicked, and never cleared after: the run follows itself
@@ -95,21 +116,40 @@
let jsonView = $state(false)
// However the run landed, that is what the card opens on.
const autoTab = $derived(settled ? 'outcome' : ran ? 'logs' : 'input')
const autoTab = $derived(hasOutcome ? 'outcome' : ran ? 'logs' : 'input')
const activeTab = $derived(userTab && tabs.some((t) => t.value === userTab) ? userTab : autoTab)
// Keyed by call id: a bare flag would carry one card's collapse onto the next message
// reusing this instance. Open by default, since the run is what was asked for.
let toggled = $state<{ id: string; open: boolean } | undefined>(undefined)
const expanded = $derived(toggled?.id === message.tool_call_id ? toggled.open : true)
// The panel mounts the chat's own form on this call, so the card must not mount a second
// one: two views binding the one draft would each reorder the schema SchemaForm edits in
// place. Only the form is exclusive. A run shows in both, and collapsing the row or
// closing the tab is the user's own way out of seeing it twice.
const formInPreview = $derived(
pending && (aiChatManager.isRunFormInPreview?.(message.tool_call_id) ?? false)
)
let bodyEl: HTMLDivElement | undefined = $state()
// One scroll region serves every tab, so a switch has to rewind it: the logs leave it
// at the tail, and the tab opened next would start part-way down its own content.
const fades = scrollFades()
// The arguments table brings its own surface, so a fade ending in the card's would seam
// against it; logs and a result stand on the body's own ground and take it cleanly.
const fadeBody = $derived(!jsonView && (activeTab === 'logs' || activeTab === 'outcome'))
// One scroll region serves every tab, so a switch has to place it: logs open on their
// end, which is where a run is read from, and everything else on its start — otherwise
// the tab opened after the logs would begin part-way down its own content.
$effect(() => {
void activeTab
void jsonView
if (bodyEl) bodyEl.scrollTop = 0
if (!bodyEl) return
bodyEl.scrollTop = activeTab === 'logs' && !jsonView ? bodyEl.scrollHeight : 0
})
// Follow the tail while the job writes: a log stream the user has to scroll to read
// is not following the run.
// And stay on the end while the job writes: a log stream the user has to scroll to
// read is not following the run.
$effect(() => {
void logs
if (!bodyEl || jsonView || activeTab !== 'logs' || !running) return
@@ -129,17 +169,28 @@
const duration = $derived(
chatJob?.durationMs !== undefined ? msToReadableTime(chatJob.durationMs, 2) : ''
)
const startedAt = $derived(displayDate(chatJob?.job?.started_at ?? undefined, true, false))
const worker = $derived(chatJob?.job?.worker ?? '')
// The run's own coordinates, on the row where a card ends. No duration: the header
// already carries it, and next to the status is where it means something. Empty parts
// are dropped rather than left as stray separators, since a job that has not reported
// yet has none.
const footerParts = $derived(
running ? [logLineCount > 0 ? `${logLineCount} lines` : '', worker] : [worker, startedAt]
// The card outlives its job, and sometimes precedes it: a call cancelled before Run never
// had one, and one that failed to start has none either. Synthesizing the shape
// JobStatusIcon discriminates on keeps a single vocabulary of status badges rather than a
// second one for the states only the card knows about.
const statusJob = $derived(
chatJob?.job ??
((canceled
? { canceled: true, success: false }
: failed
? { success: false, canceled: false }
: { running: false }) as unknown as Job)
)
// The badge carries the outcome, so this is only ever how long it took, and 'Not run' where
// there is no time to give because nothing ran.
const statusTime = $derived(
running
? elapsed
: ran
? duration || (failed ? 'Failed' : canceled ? 'Cancelled' : 'Done')
: 'Not run'
)
const footer = $derived(footerParts.filter(Boolean).join(' · '))
// What the preview button opens changes with the card: the form while the call is still
// waiting on one, the run once a job exists. Neither, and there is nothing to open, so
@@ -154,14 +205,6 @@
? ('run' as const)
: undefined
)
// True while the panel holds this call, form or run: read through the resolver so this
// tracks the tab list, not the pane's mounted tab.
const inPreview = $derived(
aiChatManager.isCallInPreview?.({
toolCallId: message.tool_call_id,
jobId: chatJob?.jobId
}) ?? false
)
const previewTitle = $derived(
previewTarget === 'form'
? 'Open this form in the preview panel'
@@ -190,124 +233,66 @@
}
</script>
<!-- The run page's own status badge, so a run reads the same wherever it is met, with the
time beside it: the badge says how it went, the number how long it took, and while it
runs that number is still moving. Ahead of the label, so the chevron stays next to what
it opens and the preview chip keeps the other end of the row to itself. -->
{#snippet status()}
{#if !pending}
<span class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap text-2xs text-hint">
<JobStatusIcon job={statusJob} roundedFull size={11} badgeClass="p-1" />
{statusTime}
</span>
{/if}
{/snippet}
<!-- The chip every other tool row opens its preview with, pointed at this call: the form
on its way in, the run on its way out. Not a toggle — pressing it again focuses the
tab it already opened. The row's only control, as on every other tool call. -->
{#snippet previewChip()}
<ToolPreviewCard
card={{ kind: 'script', path: runForm.path }}
title={previewTitle}
onOpen={openPreview}
/>
{/snippet}
<!-- scroll-mb clears the chat's sticky "Waiting for your input" chip so the mount
scrollIntoView of the form below leaves the Run button uncovered. -->
<div
class="scroll-mb-8 flex flex-col rounded-md border border-border-light bg-surface-tertiary shadow-sm"
<ChatCollapsibleCard
{label}
{expanded}
onToggle={() => (toggled = { id: message.tool_call_id, open: !expanded })}
headerLeft={status}
headerRight={previewTarget ? previewChip : undefined}
class="scroll-mb-8"
contentClass="p-0 overflow-hidden"
>
<div class="flex items-start gap-2 p-3">
<!-- The script's own kind icon, as `getJobKindIcon` gives it everywhere else. Run belongs
to the button that runs it, not to the heading of the thing being run. -->
<Code class="mt-0.5 h-4 w-4 shrink-0 text-accent" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-semibold text-emphasis">
Run {runForm.summary || runForm.path}
</p>
{#if runForm.summary}
<p class="truncate font-mono text-2xs text-secondary">{runForm.path}</p>
{/if}
</div>
{#if !pending || previewTarget}
<div class="flex shrink-0 items-center gap-1">
<!-- Status and time read as one thing: the colour and the dot say how it went, the
number says how long it took, and while it runs that number is still moving. -->
{#if pending}
<!-- Nothing to report yet, and nothing to read as JSON either. -->
{:else if running}
<span
class="inline-flex items-center gap-1.5 whitespace-nowrap text-2xs font-medium text-blue-600 dark:text-blue-400"
>
<Loader2 class="h-3 w-3 animate-spin" />
{elapsed}
</span>
{:else if failed}
<span
class="inline-flex items-center gap-1.5 whitespace-nowrap text-2xs font-medium text-red-600 dark:text-red-400"
>
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-current"></span>
Failed
</span>
{:else if canceled && ran}
<!-- A cancelled run ends on an execution error like any other, and it still took
the time it took: red, with the clock rather than the word, since the outcome
tab is already the one saying it was stopped. -->
<span
class="inline-flex items-center gap-1.5 whitespace-nowrap text-2xs font-medium text-red-600 dark:text-red-400"
>
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-current"></span>
{duration || 'Cancelled'}
</span>
{:else if canceled}
<!-- Nothing ran, so nothing errored. -->
<span class="inline-flex items-center gap-1.5 whitespace-nowrap text-2xs text-tertiary">
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-current"></span>
Not run
</span>
{:else}
<span
class="inline-flex items-center gap-1.5 whitespace-nowrap text-2xs font-medium text-green-600 dark:text-green-400"
>
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-current"></span>
{duration || 'Done'}
</span>
{/if}
{#if !pending && !inPreview}
<!-- One button, not a pair: pressed means the raw JSON of the whole call, the
shape every other tool card in the chat is read in. Gone while the panel
holds the call: there is no body here for it to switch. -->
<Button
iconOnly
variant="default"
unifiedSize="sm"
btnClasses="h-[23px] min-h-[23px] w-[23px] p-0"
selected={jsonView}
title="Show this call as raw JSON"
startIcon={{ icon: Braces }}
onClick={() => (jsonView = !jsonView)}
/>
{/if}
{#if previewTarget}
<!-- One control for the whole call: the form on its way in, the run on its way
out. Not a toggle — pressing it again focuses the tab it already opened. -->
<Button
iconOnly
variant="default"
unifiedSize="sm"
btnClasses="h-[23px] min-h-[23px] w-[23px] p-0"
title={previewTitle}
startIcon={{ icon: PanelRight }}
onClick={openPreview}
/>
{/if}
</div>
{/if}
</div>
{#if inPreview}
<!-- The panel is showing this call, so the card does not show it twice; closing that
tab brings the body back. For the form that is a requirement rather than a
preference: two mounted copies would be two views binding the one draft, each
reordering the schema SchemaForm edits in place. -->
<div class="border-t border-border-light px-3 py-2 text-2xs leading-4 text-hint">
{pending
? 'The parameters are open in the preview panel.'
: 'This run is open in the preview panel.'}
{#if formInPreview}
<div class="px-3 py-2 text-2xs leading-4 text-hint">
These inputs are open in the preview panel.
</div>
{:else if pending}
<RunArgsFormDisplay toolCallId={message.tool_call_id} {runForm} />
{:else}
<!-- One fixed-height region holding the strip and the body, so the card is the same
size on every tab, in every state, and with the strip gone in JSON: what the
strip gives up, the body takes. A cap here would not do it — the body is a
scroll region, and a max-height silently beats flex-grow. -->
<div class="flex h-[14.5rem] flex-col">
{#if !jsonView}
<Tabs
selected={activeTab}
on:selected={(e) => (userTab = e.detail)}
class="border-t border-border-light px-3"
wrapperClass="shrink-0"
>
<!-- One fixed-height region holding the strip and the body, so the card is the same size
on every tab and switching to the raw JSON does not resize it under the cursor. A cap
would not do it — the body is a scroll region, and a max-height silently beats
flex-grow. -->
<div class="relative flex h-[20rem] flex-col">
<!-- The tabs go in raw view: they name the parts of the body, and the raw call is not
one of them. The strip stays because the switch out of raw lives there — the run
page's own JSON toggle, which carries its label and so does not read as a fourth
tab. -->
<!-- The strip's own height, not one its contents happen to add up to: the tabs leave
in raw view, and a row sized by what is in it would step every time they do. -->
<Tabs
selected={activeTab}
on:selected={(e) => (userTab = e.detail)}
class="h-8 px-3"
wrapperClass="shrink-0"
>
{#if !jsonView}
{#each tabs as tab (tab.value)}
<!-- leading-4 and the tighter padding are the strip's height: text-2xs
inherits a 22px line box, which with Tab's own padding puts 12px of air
@@ -326,114 +311,153 @@
{/snippet}
</Tab>
{/each}
</Tabs>
{/if}
{/if}
<div class="ml-auto flex items-center pl-2">
<Toggle
bind:checked={jsonView}
size="2xs"
options={{ right: 'JSON', rightTooltip: 'Show this call as raw JSON' }}
lightMode
/>
</div>
</Tabs>
<!-- Logs and raw JSON sit on the sunken surface, the way program output is shown
everywhere else; the two rendered views keep the card's own surface. -->
<!-- Logs sit on the softer surface, the way program output is shown everywhere else.
The raw view keeps the card's own, as an ordinary tool call has it. -->
<div
bind:this={bodyEl}
use:fades.container
onscroll={fades.measure}
class={twMerge(
'min-h-0 flex-1 overflow-auto px-3 py-2',
jsonView || activeTab === 'logs' ? 'bg-surface-sunken' : ''
!jsonView && activeTab === 'logs' ? 'bg-surface-secondary/50' : ''
)}
>
{#if jsonView}
<div class="space-y-3">
<ToolContentDisplay title="Parameters" content={message.parameters} />
<ToolContentDisplay title="Logs" content={message.logs} />
<ToolContentDisplay title="Result" content={message.result} error={message.error} />
</div>
{:else if activeTab === 'input'}
<!-- What the runs page shows a finished job's arguments as, for the same reason the
<!-- min-h-full rather than h-full: the states that centre themselves need the height,
and a box that always filled it would measure as never scrollable. -->
<div use:fades.content class="flex min-h-full flex-col">
{#if jsonView}
<div class="space-y-3">
<!-- Each block scrolls on its own, so each fades on its own. -->
<ToolContentDisplay title="Parameters" content={message.parameters} showFade />
<ToolContentDisplay title="Logs" content={message.logs} tail showFade />
<ToolContentDisplay
title="Result"
content={message.result}
error={message.error}
showFade
/>
</div>
{:else if activeTab === 'input'}
<!-- What the runs page shows a finished job's arguments as, for the same reason the
Result tab is DisplayResult: the operator has already read this table. The job id
is what lets it fetch arguments too big to have been persisted with the card. -->
<JobArgs
args={parameters}
id={chatJob?.jobId}
workspace={chatJob?.workspace}
disableExpand
/>
{:else if activeTab === 'logs'}
{#if logs.trim()}
{#if logs.length >= MAX_LOG_LENGTH}
<p class="mb-1 text-2xs text-tertiary">
Tail of the logs, the last {MAX_LOG_LENGTH} characters.
</p>
<JobArgs
args={parameters}
id={chatJob?.jobId}
workspace={chatJob?.workspace}
disableExpand
/>
{:else if activeTab === 'logs'}
{#if logs.trim()}
{#if logs.length >= MAX_LOG_LENGTH}
<p class="mb-1 text-2xs text-tertiary">
Tail of the logs, the last {MAX_LOG_LENGTH} characters.
</p>
{/if}
<pre class="whitespace-pre-wrap break-words font-mono text-2xs text-primary"
>{logs}</pre
>
{:else}
<p class="text-2xs text-tertiary">No logs yet.</p>
{/if}
<pre class="whitespace-pre-wrap break-words font-mono text-2xs text-primary">{logs}</pre
{#if running}
<div class="mt-1 flex items-center gap-1.5 text-2xs text-tertiary">
<Loader2 class="h-3 w-3 animate-spin" />
streaming
</div>
{/if}
{:else if failed}
<pre
class="whitespace-pre-wrap break-words font-mono text-2xs text-red-700 dark:text-red-300"
>{message.error}</pre
>
{:else}
<p class="text-2xs text-tertiary">No logs yet.</p>
{/if}
{#if running}
<div class="mt-1 flex items-center gap-1.5 text-2xs text-tertiary">
<Loader2 class="h-3 w-3 animate-spin" />
streaming
</div>
{/if}
{:else if failed}
<pre
class="whitespace-pre-wrap break-words font-mono text-2xs text-red-700 dark:text-red-300"
>{message.error}</pre
>
{:else if resultValue !== undefined}
<!-- The run page's own renderer, not a second one invented for the chat: it is
{:else if streaming}
<!-- The same renderer as a landed result, handed the partial: it is the one that
knows how to show a result arriving in pieces. -->
<DisplayResult
result={undefined}
result_stream={resultStream}
jobId={chatJob?.jobId}
workspaceId={chatJob?.workspace}
disableExpand
hideAsJson
/>
{:else if resultValue !== undefined}
<!-- The run page's own renderer, not a second one invented for the chat: it is
what the result already looks like everywhere else, and it is the only thing
that handles markdown, tables, images, S3 files and deep nesting without the
card guessing at the shape. `disableExpand` drops its whole toolbar and
`hideAsJson` its Pretty/JSON switch: the header already owns both, opening it
`hideAsJson` its Pretty/JSON switch: the row already owns both, opening it
bigger and reading it raw. jobId and workspace still let it reach the job for
an S3 preview. -->
<DisplayResult
result={resultValue}
jobId={chatJob?.jobId}
workspaceId={chatJob?.workspace}
disableExpand
hideAsJson
/>
{:else if canceled}
<!-- All that is left to render is the fact itself: a form cancelled before Run
never reached a job, so there is no result the way a cancelled run has one. -->
<div class="flex h-full flex-col items-center justify-center gap-1.5 px-4 text-center">
<Ban class="h-4 w-4 text-tertiary" />
<p class="text-2xs font-medium leading-4 text-secondary">{cancelReason}</p>
{#if !ran}
<p class="text-2xs leading-4 text-tertiary">
The parameters it would have run with are on the Parameters tab.
</p>
{/if}
</div>
{:else}
<p class="text-2xs text-tertiary">This run returned no result.</p>
{/if}
<DisplayResult
result={resultValue}
jobId={chatJob?.jobId}
workspaceId={chatJob?.workspace}
disableExpand
hideAsJson
/>
{:else if canceled}
<!-- All that is left to render is the fact itself: a form cancelled before Run
never reached a job, so there is no result the way a cancelled run has one. -->
<div class="flex flex-1 flex-col items-center justify-center gap-1.5 px-4 text-center">
<Ban class="h-4 w-4 text-tertiary" />
<p class="text-2xs font-medium leading-4 text-secondary">{cancelReason}</p>
{#if !ran}
<p class="text-2xs leading-4 text-tertiary">
The inputs it would have run with are on the Inputs tab.
</p>
{/if}
</div>
{:else}
<p class="text-2xs text-tertiary">This run returned no result.</p>
{/if}
</div>
</div>
<!-- Over the body, not inside it: what is still below fades out, and nothing at the top,
as on the form and on the tool cards. Two layers on the logs, whose ground is the
card's surface with the softer one at half strength over it — one gradient would end
on the wrong colour and leave a band at the very edge. -->
{#if fades.bottom && fadeBody}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-[min(2rem,25%)] bg-gradient-to-t from-surface via-surface/60 to-transparent"
></div>
{#if activeTab === 'logs'}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-[min(2rem,25%)] bg-gradient-to-t from-surface-secondary/50 via-surface-secondary/30 to-transparent"
></div>
{/if}
{/if}
</div>
<!-- Where the run itself is named: which worker took it, how long it took, when it
started. It sits outside the fixed region, so the JSON view keeps it too. -->
<div
class="flex items-center gap-2 border-t border-border-light px-3 py-1.5 text-2xs leading-4 text-hint"
class:hidden={!footer && !(running && chatJob)}
>
<span class="truncate">{footer}</span>
{#if running && chatJob}
<span class="flex-1"></span>
<!-- The run page's own cancel button, down to the icon: stopping a run is the same
act here, and the operator has already pressed this one. -->
{#if running && chatJob}
<!-- Where the form keeps its own actions, so the button that stops a run and the one
that starts it sit in the same corner of the same card. The run page's own cancel
button, down to the icon: the operator has already pressed this one. -->
<div class="flex justify-end border-t border-border-light px-3 py-2">
<Button
variant="accent"
unifiedSize="sm"
destructive
startIcon={{ icon: TimerOff }}
btnClasses="h-[26px] min-h-[26px] px-2.5"
wrapperClasses="shrink-0"
title="Cancel the script"
onClick={() => aiChatManager.cancelJob(chatJob.jobId)}
>
Cancel
</Button>
{/if}
</div>
</div>
{/if}
{/if}
</div>
</ChatCollapsibleCard>
@@ -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<HTMLDivElement | undefined>()
$effect(() => {
void content
if (tail && scroller) scroller.scrollTop = scroller.scrollHeight
})
</script>
{#if showWhileLoading || (!loading && hasContent) || streaming}
@@ -117,6 +127,7 @@
{:else if hasContent}
<div class="relative">
<div
bind:this={scroller}
use:fadeContainer
onscroll={measureFades}
class="overflow-x-auto max-h-28 overflow-y-auto"
@@ -127,7 +138,7 @@
</div>
{#if showFade && fades.bottom}
<div
class="absolute bottom-0 left-0 right-0 h-16 pointer-events-none bg-gradient-to-t from-surface via-surface/70 via-surface/40 to-transparent"
class="absolute bottom-0 left-0 right-0 h-[min(2rem,25%)] pointer-events-none bg-gradient-to-t from-surface via-surface/70 via-surface/40 to-transparent"
></div>
{/if}
</div>
@@ -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 }}
@@ -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)
@@ -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<ToolDisplayMessage>
}
/** 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<ToolDisplayMe
return {
content: 'Background job canceled',
result: formatResult(job.result),
logs: formatLogs(job.logs)
logs: formatLogs(job.logs),
resultStream: undefined
}
}
return {
content: `Background job ${job.success ? 'completed successfully' : 'failed'}`,
result: formatResult(job.result),
logs: formatLogs(job.logs),
// The partial is the result now — see the inline terminal branch.
resultStream: undefined,
...(job.success ? {} : { error: getErrorMessage(job.result) })
}
}
@@ -1902,6 +1960,9 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
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) })
})
@@ -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()
</script>
<div class="flex items-center justify-start">
{#if isExternal}
<Badge color="gray" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}>
<ShieldQuestion size={14} />
<Badge color="gray" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}
class={badgeClass}>
<ShieldQuestion size={size} />
</Badge>
{:else if job.canceled && 'success' in job}
<Badge color="gray" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'} title="Canceled">
<Ban size={14} />
<Badge color="gray" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}
class={badgeClass} title="Canceled">
<Ban size={size} />
</Badge>
{:else if 'success' in job && job.success}
{#if job.is_skipped}
<Badge color="green" {roundedFull} baseClass={roundedFull ? '' : ''}>
<FastForward size={14} />
<Badge color="green" {roundedFull} baseClass={roundedFull ? '' : ''}
class={badgeClass}>
<FastForward size={size} />
</Badge>
{:else}
<Badge color="green" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}>
<Check size={14} />
<Badge color="green" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}
class={badgeClass}>
<Check size={size} />
</Badge>
{/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"
>
<Wrench size={14} />
<Wrench size={size} />
</Badge>
{:else if 'success' in job}
<Badge color="red" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}>
<X size={14} />
<Badge color="red" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}
class={badgeClass}>
<X size={size} />
</Badge>
{:else if 'running' in job && job.running && job.suspend}
<Badge color="violet" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'} title="Suspended">
<Hourglass size={14} />
<Badge color="violet" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}
class={badgeClass} title="Suspended">
<Hourglass size={size} />
</Badge>
{:else if 'running' in job && job.running}
<Badge color="yellow" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}>
<Play size={14} />
<Badge color="yellow" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}
class={badgeClass}>
<Play size={size} />
</Badge>
{:else if job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for)}
<Badge color="blue" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}>
<Calendar size={14} />
<Badge color="blue" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}
class={badgeClass}>
<Calendar size={size} />
</Badge>
{:else}
<Badge {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}>
<Hourglass size={14} />
<Badge {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'} class={badgeClass}>
<Hourglass size={size} />
</Badge>
{/if}
</div>
@@ -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 })