mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
The run card stays in the chat by default. Preview opens the item's deployed page in the preview panel with the proposed arguments already filled, and Run there hands them back to the parked tool call — so the chat still owns the job and reports on its result. A chat flow has no run form to fill: its page is a conversation, so the proposed message goes into the composer and a strip above it says what was asked for. Sending it runs the flow in that conversation, which settles the call with a reason rather than a refusal — the card says "Handed off", not "Cancelled". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
734 lines
31 KiB
Svelte
734 lines
31 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte'
|
|
import { cubicOut } from 'svelte/easing'
|
|
import { prefersReducedMotion } from 'svelte/motion'
|
|
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 DisplayResult from '$lib/components/DisplayResult.svelte'
|
|
import { msToReadableTime } from '$lib/utils'
|
|
import JobArgs from '$lib/components/JobArgs.svelte'
|
|
import { JobService, type CompletedJob, type Job } from '$lib/gen'
|
|
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 {
|
|
deriveChatJobStatus,
|
|
isActiveRunForm,
|
|
MAX_LOG_LENGTH,
|
|
type ToolDisplayMessage
|
|
} from './shared'
|
|
|
|
const aiChatManager = getAiChatManager()
|
|
|
|
const LOGS_UNREADABLE = { logs: '', logsFailed: true } as const
|
|
|
|
interface Props {
|
|
message: ToolDisplayMessage
|
|
}
|
|
|
|
let { message }: Props = $props()
|
|
|
|
const runForm = $derived(message.runForm)
|
|
// The run this call inspected instead of starting. Exclusive with runForm: a call
|
|
// either ran something or looked at a run.
|
|
const inspected = $derived(message.inspectedRun)
|
|
// The loop is parked on the form and nothing has run yet: the card is the form.
|
|
const pending = $derived(isActiveRunForm(message))
|
|
|
|
// An inspected run is read from the job itself rather than from the tool result, which
|
|
// is the copy capped for the model. Fetched on first expand, and only then: a transcript
|
|
// of collapsed inspections must not fire a request per row.
|
|
let fetched = $state<
|
|
{ callId: string; job: Job; logs: string; logsFailed?: boolean } | undefined
|
|
>(undefined)
|
|
let fetchFailed = $state<string | undefined>(undefined)
|
|
// Keyed by call id like the rest of this card's state, not by the job it read: a summarized
|
|
// transcript hands a surviving instance a different row, and two rows can inspect one job
|
|
// at different moments, so a job-keyed snapshot would serve the earlier row's reading.
|
|
const inspectedJob = $derived(fetched?.callId === message.tool_call_id ? fetched : undefined)
|
|
|
|
const inspectedStatus = $derived(inspectedJob ? deriveChatJobStatus(inspectedJob.job) : undefined)
|
|
|
|
const chatJob = $derived(
|
|
aiChatManager.backgroundJobs.find((j) => j.toolCallId === message.tool_call_id)
|
|
)
|
|
// The run the card is about, whichever way it got here. The inspected one is known by
|
|
// id before it is read, which is what lets the preview chip work on a collapsed card.
|
|
const job = $derived(
|
|
chatJob ??
|
|
(inspected
|
|
? {
|
|
jobId: inspected.jobId,
|
|
workspace: inspected.workspace,
|
|
status: inspectedStatus
|
|
}
|
|
: undefined)
|
|
)
|
|
const runnableKind = $derived(
|
|
runForm?.runnableKind ??
|
|
(inspectedJob?.job.job_kind === 'flow' || inspectedJob?.job.job_kind === 'flowpreview'
|
|
? 'flow'
|
|
: 'script')
|
|
)
|
|
// Declining the form, stopping the turn and cancelling the job all land here, and none
|
|
// of them is a failure: the run stopped because someone said so.
|
|
const canceled = $derived(
|
|
Boolean(message.declinedByUser) || Boolean(runForm?.canceled) || job?.status === 'canceled'
|
|
)
|
|
const failed = $derived(
|
|
inspected ? inspectedStatus === 'failure' : Boolean(message.error) && !canceled
|
|
)
|
|
// A cancelled form never reached a job, so it has no logs and no outcome to offer.
|
|
const ran = $derived(
|
|
Boolean(runForm?.started) || Boolean(message.logs) || message.result !== undefined || !!job
|
|
)
|
|
// A run can outlive the turn that started it, so "the tool call returned" is not
|
|
// "the run finished": a detached job keeps the card in its running state until
|
|
// the background poller lands an outcome on it or the tray sees the job end. An
|
|
// inspected run has no poller behind it — the status it was read at is the answer.
|
|
const settled = $derived(
|
|
inspected
|
|
? ['success', 'failure', 'canceled'].includes(inspectedStatus ?? '')
|
|
: !pending &&
|
|
!message.isLoading &&
|
|
(message.result !== undefined ||
|
|
failed ||
|
|
canceled ||
|
|
(chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status)))
|
|
)
|
|
const running = $derived(!pending && !settled)
|
|
// The job the card is about has not been read yet, or could not be: no pane has anything
|
|
// to show, but the call's own result still has.
|
|
const jobPending = $derived(Boolean(inspected) && !inspectedJob)
|
|
|
|
// An inspected run's panes come from the job: the tool's own parameters are the
|
|
// address it was called with, and its result is the model's abridged view.
|
|
const parameters = $derived(
|
|
inspected
|
|
? (inspectedJob?.job.args ?? {})
|
|
: message.parameters && typeof message.parameters === 'object'
|
|
? message.parameters
|
|
: {}
|
|
)
|
|
const logs = $derived(
|
|
inspected ? (inspectedJob?.logs ?? '') : 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
|
|
// pretty view buys. A string that happens to be JSON parses back as JSON, and the
|
|
// text it was stored as is one toggle away in the raw view.
|
|
const resultValue = $derived.by(() => {
|
|
// Only a completed job carries a result; a queued or running one has none, and reading
|
|
// it off that job is a type error rather than an undefined.
|
|
if (inspected)
|
|
return inspectedJob && 'success' in inspectedJob.job
|
|
? (inspectedJob.job as CompletedJob).result
|
|
: undefined
|
|
if (message.result === undefined) return undefined
|
|
if (typeof message.result !== 'string') return message.result
|
|
try {
|
|
return JSON.parse(message.result)
|
|
} catch {
|
|
return message.result
|
|
}
|
|
})
|
|
// 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. A
|
|
// test says so, since what it ran is the draft rather than what is deployed.
|
|
const verbs = $derived(
|
|
runForm?.kind === 'test'
|
|
? { present: 'Testing', past: 'Tested', future: 'Test' }
|
|
: { present: 'Running', past: 'Ran', future: 'Run' }
|
|
)
|
|
// Where the runnable is filed, for the preview chip's title. An inspected preview run
|
|
// has no path at all, so the card falls back to naming the job.
|
|
const path = $derived(runForm?.path ?? inspectedJob?.job.script_path ?? '')
|
|
// What the script is called on its own page and in the picker, so the row names the thing
|
|
// that ran rather than where it is filed. Not every script has one, so the path stays the
|
|
// fallback — and stays on the preview chip either way, since two folders can hold one name.
|
|
// An inspection names the run instead: it is about that run, the address is what the call
|
|
// was made with, and naming the runnable would rewrite the row once the job is read.
|
|
const runnableName = $derived(
|
|
inspected
|
|
? `${inspected.step ? `step ${inspected.step} of ` : ''}run ${inspected.runId}`
|
|
: runForm?.summary || runForm?.path || ''
|
|
)
|
|
// Inspecting is done the moment the tool returned, whatever the run it looked at is
|
|
// still doing — the tense belongs to the call, not to its subject.
|
|
const verb = $derived(
|
|
inspected ? 'Inspected' : running ? verbs.present : settled && ran ? verbs.past : verbs.future
|
|
)
|
|
|
|
// 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(
|
|
failed
|
|
? 'Error'
|
|
: message.ranElsewhere
|
|
? 'Handed off'
|
|
: canceled
|
|
? ran
|
|
? 'Cancelled'
|
|
: 'Not run'
|
|
: 'Result'
|
|
)
|
|
// What the tool call ended as, which for a handover is not a cancellation: the job exists,
|
|
// it is just not this call's to report — the page the reader ran it from has it.
|
|
const cancelReason = $derived(
|
|
message.ranElsewhere
|
|
? `This ${runnableKind} was run from its own page, so this call started no job.`
|
|
: ran
|
|
? `This run was cancelled while the ${runnableKind} was running.`
|
|
: `This run was cancelled before the ${runnableKind} 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([
|
|
{ value: 'input', label: 'Inputs' },
|
|
...(ran ? [{ value: 'logs', label: 'Logs' }] : []),
|
|
...(hasOutcome ? [{ value: 'outcome', label: outcomeTab }] : [])
|
|
])
|
|
|
|
// Keyed by call id: this instance is reused when the message at its index changes, so a
|
|
// bare flag would hand one card's view to whichever run lands in the slot next. Unset
|
|
// until a tab is clicked and never cleared, so the run stops following itself once
|
|
// the user has steered the card.
|
|
let userTab = $state<{ id: string; value: string } | undefined>(undefined)
|
|
let jsonView = $state<{ id: string; on: boolean } | undefined>(undefined)
|
|
const steered = $derived(userTab?.id === message.tool_call_id ? userTab.value : undefined)
|
|
const rawView = $derived(jsonView?.id === message.tool_call_id ? jsonView.on : false)
|
|
|
|
// However the run landed, that is what the card opens on.
|
|
const autoTab = $derived(hasOutcome ? 'outcome' : ran ? 'logs' : 'input')
|
|
const activeTab = $derived(steered && tabs.some((t) => t.value === steered) ? steered : 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 —
|
|
// except for an inspection, which is usually a step in the reasoning rather than
|
|
// the answer, and which pays a fetch for being opened. A run cancelled before it started
|
|
// is closed too: the row already says Cancelled, and there is no run to show.
|
|
let toggled = $state<{ id: string; open: boolean } | undefined>(undefined)
|
|
const expanded = $derived(
|
|
toggled?.id === message.tool_call_id ? toggled.open : !inspected && (ran || !canceled)
|
|
)
|
|
|
|
$effect(() => {
|
|
const target = inspected
|
|
// Collapsing clears a failure so reopening tries again, which is how the rest of the
|
|
// chat treats a load that did not land — a dropped connection must not be permanent.
|
|
if (!target || !expanded) {
|
|
fetchFailed = undefined
|
|
// A job whose logs did not land is dropped with it: the job itself is cached, so
|
|
// reopening would otherwise keep serving the unreadable logs for the session.
|
|
if (fetched?.logsFailed) fetched = undefined
|
|
return
|
|
}
|
|
const callId = message.tool_call_id
|
|
if (fetched?.callId === callId || fetchFailed === callId) return
|
|
const jobReq = JobService.getJob({
|
|
workspace: target.workspace,
|
|
id: target.jobId,
|
|
noCode: true,
|
|
noLogs: true
|
|
})
|
|
// The dedicated endpoint, as get_run uses it, and the whole log does come down for a
|
|
// 4000-char tail. The cheap reads cannot replace it: the job's own `logs` field is
|
|
// `right(job_logs.logs, 20000)`, and compaction leaves as few as 3000 characters in
|
|
// that column, so a large log would show less here than the model was given.
|
|
const logsReq = JobService.getJobLogs({
|
|
workspace: target.workspace,
|
|
id: target.jobId,
|
|
removeAnsiWarnings: true
|
|
})
|
|
let live = true
|
|
Promise.all([
|
|
// Something back is the success test, not "it did not throw": the generated client
|
|
// resolves nothing when it cannot read the body.
|
|
jobReq.then((j) => j ?? Promise.reject(new Error('job unreadable'))),
|
|
// Here an empty string is a real answer — a flow's own job prints nothing.
|
|
logsReq.then(
|
|
(l) => (typeof l === 'string' ? { logs: l.slice(-MAX_LOG_LENGTH) } : LOGS_UNREADABLE),
|
|
() => LOGS_UNREADABLE
|
|
)
|
|
])
|
|
.then(([j, l]) => {
|
|
if (live) fetched = { callId, job: j, ...l }
|
|
})
|
|
.catch(() => {
|
|
if (live) fetchFailed = callId
|
|
})
|
|
return () => {
|
|
live = false
|
|
jobReq.cancel()
|
|
logsReq.cancel()
|
|
}
|
|
})
|
|
|
|
// 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)
|
|
)
|
|
|
|
// The three moments a run moves the card — a tab arrives, the selection follows it, the body
|
|
// changes under it — are staggered rather than landing in one frame, which is what makes the
|
|
// card followable. A tab already on screen when the card mounts never arrived, so a settled
|
|
// call restored from history renders its strip at rest.
|
|
let liveStrip = $state(false)
|
|
onMount(() => (liveStrip = true))
|
|
|
|
function growIn(node: HTMLElement, { live }: { live: boolean }) {
|
|
const width = node.getBoundingClientRect().width
|
|
return {
|
|
duration: live && !prefersReducedMotion.current ? 170 : 0,
|
|
easing: cubicOut,
|
|
css: (t: number) => `width:${t * width}px; opacity:${t}`
|
|
}
|
|
}
|
|
|
|
/** The body fades in over the time the bar takes to travel, so the two halves of a tab change
|
|
* land together rather than one instantly and one over 200ms. No shift with it: the logs open
|
|
* pinned to the end of their own scroll, which clips one, and a pane that moves on two tabs
|
|
* out of three reads as a glitch rather than as direction. */
|
|
function enterPane(_node: HTMLElement) {
|
|
return {
|
|
duration: liveStrip && !prefersReducedMotion.current ? 200 : 0,
|
|
easing: cubicOut,
|
|
css: (t: number) => `opacity:${t}`
|
|
}
|
|
}
|
|
|
|
let bodyEl: HTMLDivElement | undefined = $state()
|
|
|
|
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(!rawView && (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 rawView
|
|
if (!bodyEl) return
|
|
bodyEl.scrollTop = activeTab === 'logs' && !rawView ? bodyEl.scrollHeight : 0
|
|
})
|
|
|
|
// 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 || rawView || activeTab !== 'logs' || !running) return
|
|
bodyEl.scrollTop = bodyEl.scrollHeight
|
|
})
|
|
|
|
// Ticks only while this card has a job in flight, so a transcript of settled runs
|
|
// keeps no timers at all.
|
|
let now = $state(Date.now())
|
|
$effect(() => {
|
|
if (!running || !chatJob) return
|
|
const timer = setInterval(() => (now = Date.now()), 500)
|
|
return () => clearInterval(timer)
|
|
})
|
|
|
|
const elapsed = $derived(chatJob ? msToReadableTime(now - chatJob.createdAt, 2) : '')
|
|
const duration = $derived(
|
|
chatJob?.durationMs !== undefined ? msToReadableTime(chatJob.durationMs, 2) : ''
|
|
)
|
|
|
|
// The jobs bar's status hues, darkened: those are drawn for a tinted ground and land
|
|
// brighter than the name on this transparent one. Per-hue steps because the ramp is not
|
|
// uniform, and ok borrows emerald because green skips that weight.
|
|
const statusClass = $derived.by(() => {
|
|
// The card outlives its job and sometimes precedes it, so the states only it knows about
|
|
// read off its own flags rather than off a status no job is there to report.
|
|
if (canceled) return 'text-tertiary'
|
|
if (failed) return 'text-red-800 dark:text-red-300'
|
|
if (!ran) return 'text-tertiary'
|
|
switch (job?.status) {
|
|
case 'running':
|
|
return 'text-blue-800 dark:text-blue-200'
|
|
case 'suspended':
|
|
return 'text-violet-800 dark:text-violet-300'
|
|
case 'queued':
|
|
case 'scheduled':
|
|
return 'text-orange-800 dark:text-orange-300'
|
|
case 'failure':
|
|
return 'text-red-800 dark:text-red-300'
|
|
case 'success':
|
|
return 'text-green-700 dark:text-emerald-400'
|
|
default:
|
|
return settled ? 'text-green-700 dark:text-emerald-400' : 'text-blue-800 dark:text-blue-200'
|
|
}
|
|
})
|
|
// How long it took, which is the one thing the colour cannot say. A run that never started
|
|
// has no time to give, so its outcome takes the slot — as a word, never "Not run", which
|
|
// stutters against the "Run <name>" label beside it.
|
|
// A call the reader settled by running the item on its own page is a decline to this tool
|
|
// and a run to them: "Cancelled" would say the opposite of what they just watched happen.
|
|
const outcome = $derived(
|
|
failed ? 'Failed' : message.ranElsewhere ? 'Handed off' : canceled ? 'Cancelled' : 'Done'
|
|
)
|
|
const statusTime = $derived(running ? elapsed : duration || outcome)
|
|
|
|
// 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
|
|
// the button is not drawn at all — a form has nowhere to go outside a session, and a
|
|
// call cancelled before Run never became a run.
|
|
// A deployed run can be confirmed on the item's own page instead of on this card: the page
|
|
// takes over the call, so Run there is Run here. A test run has no deployed page — it
|
|
// executes the draft being written — so it keeps the chat's own form tab.
|
|
const asDeployedPage = $derived(
|
|
pending && runForm?.kind === 'run' && !!path && !!aiChatManager.openRunOnDeployedPage
|
|
)
|
|
const previewTarget = $derived(
|
|
pending
|
|
? asDeployedPage
|
|
? ('deployed' as const)
|
|
: aiChatManager.openRunForm
|
|
? ('form' as const)
|
|
: undefined
|
|
: job
|
|
? ('run' as const)
|
|
: undefined
|
|
)
|
|
const previewTitle = $derived(
|
|
previewTarget === 'form'
|
|
? `Open this form in the preview panel: ${path}`
|
|
: previewTarget === 'deployed'
|
|
? `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and its run form: ${path}`
|
|
: aiChatManager.openRunInPreview
|
|
? `Open this run in the preview panel: ${path || runnableName}`
|
|
: `Open this run in a new tab: ${path || runnableName}`
|
|
)
|
|
|
|
function openPreview() {
|
|
const label = runnableName
|
|
// The panel now shows what this card was showing, so the card folds away rather than
|
|
// holding a second copy open in the transcript. Through `toggled`, so it reads as an
|
|
// ordinary collapse the reader can undo.
|
|
if (previewTarget === 'deployed') toggled = { id: message.tool_call_id, open: false }
|
|
if (previewTarget === 'form') {
|
|
aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label })
|
|
return
|
|
}
|
|
if (previewTarget === 'deployed') {
|
|
aiChatManager.openRunOnDeployedPage?.({
|
|
toolCallId: message.tool_call_id,
|
|
kind: runnableKind === 'flow' ? 'flow' : 'script',
|
|
path,
|
|
summary: label,
|
|
args: aiChatManager.runFormDraft(message.tool_call_id, runForm!).args ?? {}
|
|
})
|
|
return
|
|
}
|
|
if (!job) return
|
|
// Outside a session there is no panel, so the run opens where the jobs tray sends it.
|
|
if (aiChatManager.openRunInPreview) {
|
|
aiChatManager.openRunInPreview({ jobId: job.jobId, workspace: job.workspace, label })
|
|
} else {
|
|
window.open(`${base}/run/${job.jobId}?workspace=${job.workspace}`, '_blank', 'noreferrer')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<!-- One readout rather than a badge beside a number: the colour says how the run went and the
|
|
text how long it took, which is how the rest of the chat states a status. While it runs
|
|
that number is still moving. `font-medium` because the row is a button and the base layer
|
|
sets those semibold, which would leave this the one bold word in the header. -->
|
|
{#snippet status()}
|
|
<!-- An inspection reports nothing here: the card is a snapshot the chat will never
|
|
update, so a status on it would be frozen at whatever the run happened to be. -->
|
|
{#if !pending && !inspected}
|
|
<span class={twMerge('shrink-0 whitespace-nowrap text-2xs font-medium', statusClass)}>
|
|
{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: runnableKind, path }}
|
|
title={previewTitle}
|
|
onOpen={openPreview}
|
|
kindIcon={false}
|
|
mode={previewTarget === 'deployed' ? 'view' : 'edit'}
|
|
/>
|
|
{/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. -->
|
|
<!-- The runnable's name is the subject of the row and the verb is grammar, so the name carries
|
|
the weight and the verb stays quiet. Held one step under the card's headings, since this is a
|
|
row in a transcript rather than a title. font-main because a name is UI text: the row around
|
|
it is font-mono, right for a path, wrong here. -->
|
|
<ChatCollapsibleCard
|
|
label={runnableName}
|
|
labelPrefix={verb}
|
|
{expanded}
|
|
onToggle={() => (toggled = { id: message.tool_call_id, open: !expanded })}
|
|
headerLeft={status}
|
|
headerRight={previewTarget ? previewChip : undefined}
|
|
class="scroll-mb-8"
|
|
labelClass="font-main font-medium text-primary"
|
|
contentClass="p-0 overflow-hidden"
|
|
>
|
|
{#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 && runForm}
|
|
<RunArgsFormDisplay toolCallId={message.tool_call_id} {runForm} />
|
|
{:else}
|
|
<!-- One region holding the strip and the body, fixed so the card is the same size on every
|
|
tab — a cap would not do it, since the body is a scroll region and a max-height
|
|
silently beats flex-grow. The raw view takes that height as a floor instead: its
|
|
blocks scroll on their own, as an ordinary tool call's do, so a scroller around them
|
|
would be one too many. -->
|
|
<div
|
|
class={twMerge(
|
|
'relative flex flex-col',
|
|
rawView ? 'min-h-[20rem]' : jobPending ? '' : 'h-[20rem]'
|
|
)}
|
|
>
|
|
<!-- The tabs go in raw view — they name the parts of the body, and the raw call is not
|
|
one of them — while the strip stays, since the JSON toggle lives there. Hence its
|
|
fixed height: a row sized by its contents would step every time the tabs leave, and
|
|
the tighter Tab padding below is what fits a label inside that height. -->
|
|
<Tabs
|
|
selected={activeTab}
|
|
on:selected={(e) => (userTab = { id: message.tool_call_id, value: e.detail })}
|
|
class="h-8 px-3 font-main"
|
|
wrapperClass="shrink-0"
|
|
slidingIndicator
|
|
>
|
|
{#if !rawView && !jobPending}
|
|
{#each tabs as tab (tab.value)}
|
|
<!-- The tab widens in first and the bar follows it, because a run adds its tabs as
|
|
it produces them: landing the selection on a tab in the frame it appears reads
|
|
as one unexplained jump. `border-b-0` because the bar is the selection now.
|
|
Size only: Tab's own colour and weight mark the selection, and this class
|
|
lands after them in its twMerge, so a colour here would silently win. -->
|
|
<span class="inline-flex overflow-hidden" in:growIn={{ live: liveStrip }}>
|
|
<Tab
|
|
value={tab.value}
|
|
label={tab.label}
|
|
class="border-b-0 py-0.5 text-2xs leading-4"
|
|
exact
|
|
>
|
|
{#snippet extra()}
|
|
{#if tab.value === 'logs' && logLineCount > 0}
|
|
<span class="text-2xs text-hint">{logLineCount}</span>
|
|
{/if}
|
|
{/snippet}
|
|
</Tab>
|
|
</span>
|
|
{/each}
|
|
{/if}
|
|
<div class="ml-auto flex items-center pl-2">
|
|
<Toggle
|
|
checked={rawView}
|
|
on:change={(e) => (jsonView = { id: message.tool_call_id, on: e.detail })}
|
|
size="2xs"
|
|
options={{ right: 'JSON', rightTooltip: 'Show this call as raw JSON' }}
|
|
lightMode
|
|
/>
|
|
</div>
|
|
</Tabs>
|
|
|
|
<!-- 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 px-3 py-2',
|
|
rawView ? '' : 'overflow-auto',
|
|
!rawView && !jobPending && activeTab === 'logs' ? 'bg-surface-secondary/50' : ''
|
|
)}
|
|
>
|
|
<!-- 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 rawView}
|
|
<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 jobPending}
|
|
<!-- The strip above stays whatever the job does: the call's own result is already
|
|
in the transcript, and the JSON toggle is how it is read. -->
|
|
<div class="text-2xs leading-4 text-hint">
|
|
{#if fetchFailed === message.tool_call_id}
|
|
This run could not be read. It may have been deleted, or be in another workspace.
|
|
Its result is on the JSON toggle.
|
|
{:else}
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<Loader2 class="h-3 w-3 animate-spin" />
|
|
Loading this run...
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- Keyed on the tab so the body arrives rather than cuts. One region serves every
|
|
tab, so only the incoming pane moves: overlapping them would ask this scroller
|
|
to hold two at once. -->
|
|
{#key activeTab}
|
|
<div class="flex min-h-full flex-1 flex-col" in:enterPane>
|
|
{#if activeTab === 'input'}
|
|
<!-- What the runs page shows a finished job's arguments as: 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={job?.jobId}
|
|
workspace={job?.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">
|
|
{inspectedJob?.logsFailed
|
|
? 'Logs could not be read.'
|
|
: running
|
|
? 'No logs yet.'
|
|
: 'No logs.'}
|
|
</p>
|
|
{/if}
|
|
{#if running && !inspected}
|
|
<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}
|
|
<!-- A run the chat started reports its failure on the tool call; an inspected one
|
|
carries it as its result, which the pane below renders the way the run page
|
|
does. `failed` excludes a cancellation, which also leaves an error on the
|
|
message but owns its own pane. -->
|
|
{:else if !inspected && failed}
|
|
<pre
|
|
class="whitespace-pre-wrap break-words font-mono text-2xs text-red-700 dark:text-red-300"
|
|
>{message.error}</pre
|
|
>
|
|
{: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={job?.jobId}
|
|
workspaceId={job?.workspace}
|
|
disableExpand
|
|
hideAsJson
|
|
/>
|
|
{:else if resultValue !== undefined}
|
|
<!-- The run page's own renderer, not a second one invented for the chat: it handles
|
|
markdown, tables, images and deep nesting without the card guessing at the
|
|
shape, and reaches the job through jobId/workspace for an S3 preview.
|
|
`disableExpand` drops its toolbar and `hideAsJson` its Pretty/JSON switch,
|
|
which the row already owns. -->
|
|
<DisplayResult
|
|
result={resultValue}
|
|
jobId={job?.jobId}
|
|
workspaceId={job?.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">
|
|
{message.ranElsewhere
|
|
? 'The inputs it was opened with are on the Inputs tab.'
|
|
: '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>
|
|
{/key}
|
|
{/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(2.5rem,35%)] 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(2.5rem,35%)] bg-gradient-to-t from-surface-secondary/50 via-surface-secondary/30 to-transparent"
|
|
></div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
{#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 }}
|
|
title={`Cancel the ${runnableKind}`}
|
|
onClick={() => aiChatManager.cancelJob(chatJob.jobId)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</ChatCollapsibleCard>
|