mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 08:01:35 +00:00
feat: rebuild the run_script card as a tool call row, with streaming
This commit is contained in:
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user