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:
AlexRV12
2026-09-03 16:30:09 +02:00
co-authored by Claude Opus 5
parent 4f122e72ab
commit 2a03b4285d
7 changed files with 532 additions and 97 deletions
+24 -13
View File
@@ -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'}`,