mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
feat: keep a chat run in its card, from the form to logs and result
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4f122e72ab
commit
2a03b4285d
@@ -21,9 +21,18 @@
|
||||
args: any
|
||||
argLabel?: string | undefined
|
||||
workspace?: string | undefined
|
||||
/** Drop the header's expand-into-a-drawer button, for a caller that already offers a
|
||||
* way to open the run in full. */
|
||||
disableExpand?: boolean
|
||||
}
|
||||
|
||||
let { id = undefined, args, argLabel = undefined, workspace = undefined }: Props = $props()
|
||||
let {
|
||||
id = undefined,
|
||||
args,
|
||||
argLabel = undefined,
|
||||
workspace = undefined,
|
||||
disableExpand = false
|
||||
}: Props = $props()
|
||||
|
||||
// Internal flag injected by "test this step" runs to suppress the asset
|
||||
// dispatcher. Not a real input: shown as a badge instead of a table row,
|
||||
@@ -125,18 +134,20 @@ ${Object.entries(displayArgs)
|
||||
<Cell head last>Value</Cell>
|
||||
</tr>
|
||||
{#snippet headerAction()}
|
||||
<div class="center-center -m-1">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
jsonStr = JSON.stringify(args, null, 4)
|
||||
jsonViewer?.openDrawer()
|
||||
}}
|
||||
iconOnly
|
||||
startIcon={{ icon: Expand }}
|
||||
></Button>
|
||||
</div>
|
||||
{#if !disableExpand}
|
||||
<div class="center-center -m-1">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
jsonStr = JSON.stringify(args, null, 4)
|
||||
jsonViewer?.openDrawer()
|
||||
}}
|
||||
iconOnly
|
||||
startIcon={{ icon: Expand }}
|
||||
></Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Head>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte'
|
||||
import { Code, Play, X } from 'lucide-svelte'
|
||||
import { Play, X } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import { processSecretArgs } from '$lib/components/secretArgUtils'
|
||||
@@ -8,6 +8,7 @@
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import { scrollFades } from './scrollFades.svelte'
|
||||
import type { RunFormDisplay } from './shared'
|
||||
|
||||
// Never the imported singleton: submitting has to resolve the pending callback of
|
||||
@@ -50,6 +51,9 @@
|
||||
void tick().then(() => cardNode?.scrollIntoView({ block: 'nearest' }))
|
||||
})
|
||||
|
||||
const fades = scrollFades()
|
||||
const { container: fadeContainer, content: fadeContent, measure: measureFades } = fades
|
||||
|
||||
async function run() {
|
||||
if (submitting || !isValid) return
|
||||
// Before processSecretArgs, not after: a card restored from history outlives the
|
||||
@@ -92,65 +96,58 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- scroll-mb clears the chat's sticky "Waiting for your input" chip so the mount
|
||||
scrollIntoView leaves the Run button uncovered. -->
|
||||
<div
|
||||
bind:this={cardNode}
|
||||
class="scroll-mb-8 flex flex-col rounded-md border border-border-light bg-surface-tertiary shadow-sm"
|
||||
data-chat-keyboard-scope="run-args-form"
|
||||
>
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<!-- The card's heading and its chrome belong to RunScriptCard, which renders this form
|
||||
as one phase of the same card. Only this phase carries the keyboard scope: the
|
||||
marker is looked up across the whole chat, so a settled card still holding it would
|
||||
stop Escape from ever cancelling a turn again. -->
|
||||
<div bind:this={cardNode} class="flex flex-col" 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="max-h-[min(28rem,50vh)] overflow-y-auto px-3"
|
||||
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. 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. -->
|
||||
<div class="sticky top-0 z-10 -mb-3 h-3 bg-gradient-to-b from-surface-tertiary to-transparent"
|
||||
></div>
|
||||
{#if hasArgs}
|
||||
<!-- The one thing here that runs before Run: a `dynselect-`/`dynmultiselect-`
|
||||
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-`
|
||||
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
|
||||
helperScript={planMode
|
||||
? undefined
|
||||
: { source: 'deployed', path: runForm.path, runnable_kind: 'script' }}
|
||||
disabled={planMode}
|
||||
{workspace}
|
||||
prettifyHeader
|
||||
lightHeader
|
||||
bind:isValid
|
||||
bind:args
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary">This script takes no arguments.</p>
|
||||
<SchemaForm
|
||||
bind:schema
|
||||
helperScript={planMode
|
||||
? undefined
|
||||
: { source: 'deployed', path: runForm.path, runnable_kind: 'script' }}
|
||||
disabled={planMode}
|
||||
{workspace}
|
||||
prettifyHeader
|
||||
lightHeader
|
||||
bind:isValid
|
||||
bind:args
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary">This script takes no arguments.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if fades.bottom}
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mt-3 h-3 bg-gradient-to-t from-surface-tertiary to-transparent"
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mt-3 h-3 bg-gradient-to-t from-surface-tertiary to-transparent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- One region with the actions: these lines report on the run the button below launches,
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
<script lang="ts">
|
||||
import { Ban, Braces, Code, Loader2, PanelRight, TimerOff } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { Button, Tab, Tabs } from '$lib/components/common'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
import { displayDate, msToReadableTime } from '$lib/utils'
|
||||
import JobArgs from '$lib/components/JobArgs.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import RunArgsFormDisplay from './RunArgsFormDisplay.svelte'
|
||||
import ToolContentDisplay from './ToolContentDisplay.svelte'
|
||||
import { isActiveRunForm, MAX_LOG_LENGTH, type ToolDisplayMessage } from './shared'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
interface Props {
|
||||
message: ToolDisplayMessage
|
||||
}
|
||||
|
||||
let { message }: Props = $props()
|
||||
|
||||
const runForm = $derived(message.runForm!)
|
||||
// The loop is parked on the form and nothing has run yet: the card is the form.
|
||||
const pending = $derived(isActiveRunForm(message))
|
||||
|
||||
const chatJob = $derived(
|
||||
aiChatManager.backgroundJobs.find((j) => j.toolCallId === message.tool_call_id)
|
||||
)
|
||||
// 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) || chatJob?.status === 'canceled'
|
||||
)
|
||||
const failed = $derived(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 || !!chatJob
|
||||
)
|
||||
// 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.
|
||||
const settled = $derived(
|
||||
!pending &&
|
||||
!message.isLoading &&
|
||||
(message.result !== undefined ||
|
||||
failed ||
|
||||
canceled ||
|
||||
(chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status)))
|
||||
)
|
||||
const running = $derived(!pending && !settled)
|
||||
|
||||
const parameters = $derived(
|
||||
message.parameters && typeof message.parameters === 'object' ? message.parameters : {}
|
||||
)
|
||||
const logs = $derived(typeof message.logs === 'string' ? message.logs : '')
|
||||
const logLineCount = $derived(logs.trim() ? logs.trimEnd().split('\n').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 click away under { }.
|
||||
const resultValue = $derived.by(() => {
|
||||
if (message.result === undefined) return undefined
|
||||
if (typeof message.result !== 'string') return message.result
|
||||
try {
|
||||
return JSON.parse(message.result)
|
||||
} catch {
|
||||
return message.result
|
||||
}
|
||||
})
|
||||
|
||||
// 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' : canceled ? (ran ? 'Cancelled' : 'Not run') : 'Result'
|
||||
)
|
||||
const cancelReason = $derived(
|
||||
ran
|
||||
? 'This run was cancelled while the script was running.'
|
||||
: 'This run was cancelled before the script started.'
|
||||
)
|
||||
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' },
|
||||
...(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 }] : [])
|
||||
])
|
||||
|
||||
// Undefined until a tab is clicked, and never cleared after: the run follows itself
|
||||
// only for as long as nobody has steered the card, and then it stops taking it back.
|
||||
let userTab = $state<string | undefined>(undefined)
|
||||
let jsonView = $state(false)
|
||||
|
||||
// However the run landed, that is what the card opens on.
|
||||
const autoTab = $derived(settled ? 'outcome' : ran ? 'logs' : 'input')
|
||||
const activeTab = $derived(userTab && tabs.some((t) => t.value === userTab) ? userTab : autoTab)
|
||||
|
||||
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.
|
||||
$effect(() => {
|
||||
void activeTab
|
||||
void jsonView
|
||||
if (bodyEl) bodyEl.scrollTop = 0
|
||||
})
|
||||
|
||||
// Follow the tail 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
|
||||
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) : ''
|
||||
)
|
||||
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]
|
||||
)
|
||||
const footer = $derived(footerParts.filter(Boolean).join(' · '))
|
||||
</script>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<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}
|
||||
<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 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}
|
||||
<!-- 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. -->
|
||||
<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)}
|
||||
/>
|
||||
<!-- Drawn, not wired: opening the run in the side panel is its own change. -->
|
||||
<Button
|
||||
iconOnly
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
btnClasses="h-[23px] min-h-[23px] w-[23px] p-0"
|
||||
disabled
|
||||
title="Open this run in the preview pane"
|
||||
startIcon={{ icon: PanelRight }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#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"
|
||||
>
|
||||
{#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
|
||||
above and below a 11px label. -->
|
||||
<Tab
|
||||
value={tab.value}
|
||||
label={tab.label}
|
||||
class="py-0.5 text-2xs font-medium leading-4 text-secondary"
|
||||
selectedClass="text-accent border-border-accent"
|
||||
exact
|
||||
>
|
||||
{#snippet extra()}
|
||||
{#if tab.value === 'logs' && logLineCount > 0}
|
||||
<span class="text-2xs text-hint">{logLineCount}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Tab>
|
||||
{/each}
|
||||
</Tabs>
|
||||
{/if}
|
||||
|
||||
<!-- 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. -->
|
||||
<div
|
||||
bind:this={bodyEl}
|
||||
class={twMerge(
|
||||
'min-h-0 flex-1 overflow-auto px-3 py-2',
|
||||
jsonView || activeTab === 'logs' ? 'bg-surface-sunken' : ''
|
||||
)}
|
||||
>
|
||||
{#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
|
||||
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>
|
||||
{/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}
|
||||
{#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
|
||||
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
|
||||
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}
|
||||
</div>
|
||||
</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. -->
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, Copy, Check } from 'lucide-svelte'
|
||||
import { TOOL_PRETTIFY_MAP } from './shared'
|
||||
import { scrollFades } from './scrollFades.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -75,32 +76,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Only draw the bottom fade when the content actually overflows and the
|
||||
// user hasn't scrolled to the bottom. `showFade` is the parent's intent;
|
||||
// `canScrollDown` is the live measurement on the inner scroll container.
|
||||
let scrollEl: HTMLDivElement | undefined = $state()
|
||||
let canScrollDown = $state(false)
|
||||
function updateCanScrollDown() {
|
||||
if (!scrollEl) {
|
||||
canScrollDown = false
|
||||
return
|
||||
}
|
||||
canScrollDown = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight > 1
|
||||
}
|
||||
|
||||
// Mount-time + content-change measurement via ResizeObserver. The
|
||||
// observer fires on initial observe (catches first paint) and on every
|
||||
// size change of the scroll container or its content (catches streaming
|
||||
// JSON growing past max-h-28). User scrolls fire `onscroll` directly.
|
||||
$effect(() => {
|
||||
if (!scrollEl) return
|
||||
updateCanScrollDown()
|
||||
const ro = new ResizeObserver(updateCanScrollDown)
|
||||
ro.observe(scrollEl)
|
||||
const inner = scrollEl.firstElementChild
|
||||
if (inner) ro.observe(inner)
|
||||
return () => ro.disconnect()
|
||||
})
|
||||
// Only draw the bottom fade when the content actually overflows and the user hasn't
|
||||
// scrolled to the bottom. `showFade` is the parent's intent; `fades.bottom` is the live
|
||||
// measurement on the inner scroll container, which catches streaming JSON growing past
|
||||
// max-h-28 as well as the first paint.
|
||||
const fades = scrollFades()
|
||||
const { container: fadeContainer, content: fadeContent, measure: measureFades } = fades
|
||||
</script>
|
||||
|
||||
{#if showWhileLoading || (!loading && hasContent) || streaming}
|
||||
@@ -136,15 +117,15 @@
|
||||
{:else if hasContent}
|
||||
<div class="relative">
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
onscroll={updateCanScrollDown}
|
||||
use:fadeContainer
|
||||
onscroll={measureFades}
|
||||
class="overflow-x-auto max-h-28 overflow-y-auto"
|
||||
>
|
||||
<pre class="text-2xs text-primary whitespace-pre-wrap"
|
||||
<pre use:fadeContent class="text-2xs text-primary whitespace-pre-wrap"
|
||||
>{formatJson($state.snapshot(content))}</pre
|
||||
>
|
||||
</div>
|
||||
{#if showFade && canScrollDown}
|
||||
{#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"
|
||||
></div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
import { isActiveRunForm, isActiveUserQuestion, type ToolDisplayMessage } from './shared'
|
||||
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
|
||||
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { slide } from 'svelte/transition'
|
||||
@@ -37,7 +37,7 @@
|
||||
import ToolMessageActions from './ToolMessageActions.svelte'
|
||||
import ToolPreviewCard from './ToolPreviewCard.svelte'
|
||||
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
|
||||
import RunArgsFormDisplay from './RunArgsFormDisplay.svelte'
|
||||
import RunScriptCard from './RunScriptCard.svelte'
|
||||
import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte'
|
||||
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
|
||||
@@ -118,7 +118,9 @@
|
||||
isActiveUserQuestion(message) ? message.userQuestion : undefined
|
||||
)
|
||||
|
||||
const activeRunForm = $derived(isActiveRunForm(message) ? message.runForm : undefined)
|
||||
// The run card owns this call from the form to whatever settled it, cancelling included:
|
||||
// the card is the call, and a run the user stopped is not a different kind of thing.
|
||||
const isRunCard = $derived(Boolean(message.runForm))
|
||||
|
||||
// The preview chip sits on the header row (to the right of the tool-call text);
|
||||
// shown once the tool settled, never while loading/erroring/awaiting confirmation.
|
||||
@@ -131,8 +133,6 @@
|
||||
|
||||
{#if activeUserQuestion}
|
||||
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
|
||||
{:else if activeRunForm}
|
||||
<RunArgsFormDisplay toolCallId={message.tool_call_id} runForm={activeRunForm} />
|
||||
{:else if message.blockedByPlanMode}
|
||||
<!-- Not an error card: the call did what plan mode says it should. One flat row
|
||||
naming the refused tool, so "why can't it edit" is answered where it is asked. -->
|
||||
@@ -145,6 +145,8 @@
|
||||
<span class="text-2xs text-tertiary truncate">{message.toolName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isRunCard}
|
||||
<RunScriptCard {message} />
|
||||
{:else if planState}
|
||||
<!-- Same lean shape as a tool call below: a header row that collapses into the
|
||||
transcript, with everything else in one box under it. -->
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Live "is there more past this edge" for one scroll region, so a fade is drawn only over
|
||||
* content there actually is: a box short enough not to scroll, or scrolled to an end, shows
|
||||
* its first and last line sharp.
|
||||
*
|
||||
* Measured rather than assumed, because the boxes it serves change height under a still
|
||||
* scroll offset — a tool result streams in, a dynamic field fills its options. Put
|
||||
* `container` on the scrolling element with `onscroll={measure}`, and `content` on the
|
||||
* element inside it whose height moves.
|
||||
*/
|
||||
export function scrollFades() {
|
||||
let node: HTMLElement | undefined = undefined
|
||||
let top = $state(false)
|
||||
let bottom = $state(false)
|
||||
// Built on first attach, never at call time: this runs during component init, where
|
||||
// ResizeObserver does not exist on the server.
|
||||
let observer: ResizeObserver | undefined = undefined
|
||||
|
||||
function measure() {
|
||||
if (!node) {
|
||||
top = false
|
||||
bottom = false
|
||||
return
|
||||
}
|
||||
top = node.scrollTop > 1
|
||||
bottom = node.scrollHeight - node.scrollTop - node.clientHeight > 1
|
||||
}
|
||||
|
||||
function observe(el: HTMLElement) {
|
||||
observer ??= new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return {
|
||||
destroy() {
|
||||
observer?.unobserve(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get top() {
|
||||
return top
|
||||
},
|
||||
get bottom() {
|
||||
return bottom
|
||||
},
|
||||
measure,
|
||||
container(el: HTMLElement) {
|
||||
node = el
|
||||
measure()
|
||||
const handle = observe(el)
|
||||
return {
|
||||
destroy() {
|
||||
handle.destroy()
|
||||
node = undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
content: observe
|
||||
}
|
||||
}
|
||||
@@ -1560,7 +1560,7 @@ export async function buildSchemaForTool(
|
||||
|
||||
// Constants for result formatting
|
||||
const MAX_RESULT_LENGTH = 12000
|
||||
const MAX_LOG_LENGTH = 4000
|
||||
export const MAX_LOG_LENGTH = 4000
|
||||
export const MAX_RUNNABLE_CONTENT_LENGTH = 20000
|
||||
|
||||
/** How long a test run is awaited inline before it detaches into the background
|
||||
@@ -1660,6 +1660,13 @@ export async function pollJobCompletion(
|
||||
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
|
||||
@@ -1769,9 +1776,14 @@ function backgroundedSummary(jobId: string, label: string): string {
|
||||
// fills its card the same way one that finished inline does.
|
||||
export function completedJobToolStatus(job: CompletedJob): Partial<ToolDisplayMessage> {
|
||||
// A canceled job isn't a `success`, but it isn't a failure either — the user
|
||||
// stopped it — so don't dress the card as an error.
|
||||
// stopped it — so don't dress the card as an error. It still has the result the run
|
||||
// page shows for a canceled run, which names who stopped it, so keep that.
|
||||
if (job.canceled) {
|
||||
return { content: 'Background job canceled', logs: formatLogs(job.logs) }
|
||||
return {
|
||||
content: 'Background job canceled',
|
||||
result: formatResult(job.result),
|
||||
logs: formatLogs(job.logs)
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: `Background job ${job.success ? 'completed successfully' : 'failed'}`,
|
||||
|
||||
Reference in New Issue
Block a user