feat: show an agent run as one scroll ending in its output

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-17 10:07:22 +02:00
co-authored by Claude Opus 5
parent c528ba41d4
commit 8343f92efe
7 changed files with 149 additions and 82 deletions
@@ -3,15 +3,15 @@
import { Badge } from '$lib/components/common'
import GfmMarkdown from './GfmMarkdown.svelte'
import AgentTrace from './AgentTrace.svelte'
import { buildAgentTrace } from './agentTrace'
import { scrollPaneToEnd } from './agentScroll'
import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult'
interface Props {
result: AgentResult
/** The answer, or what the agent did to get there; JSON is the viewer's own toggle. */
view: 'answer' | 'trace'
workspaceId?: string
/**
* How to render an answer that is not text. An `output_schema` makes `output`
* How to render an output that is not text. An `output_schema` makes `output`
* an object, and the right rendering for it is whatever the result viewer
* would do with that object on its own — a table for rows, the file viewer
* for an S3 object. Passed in rather than imported so this component does not
@@ -20,32 +20,53 @@
structuredOutput: Snippet<[unknown]>
}
let { result, view, workspaceId, structuredOutput }: Props = $props()
let { result, workspaceId, structuredOutput }: Props = $props()
let summary = $derived(summarizeAgentResult(result))
let textOutput = $derived(typeof result.output === 'string' ? result.output : undefined)
// The run's last message is what produced `output`, so it is dropped from the
// trace: the output block below is that same text, and printing it twice in
// one scroll reads as the agent having answered itself.
let trace = $derived.by(() => {
const entries = buildAgentTrace(result.messages)
return entries.at(-1)?.kind === 'assistant' ? entries.slice(0, -1) : entries
})
let anchor: HTMLElement | undefined = $state()
$effect(() => {
// Opening a run lands on its output rather than on how it got there.
anchor
scrollPaneToEnd(anchor)
})
</script>
<div class="flex flex-col gap-2 w-full pt-1">
{#if view === 'trace'}
<AgentTrace messages={result.messages} {workspaceId} />
{:else if textOutput !== undefined}
{#if textOutput === ''}
<span class="text-tertiary text-xs">The agent returned no answer</span>
{:else}
<!-- A model writes this answer, and what it writes is steerable by whatever
reached its context — a user message, a tool's output. So it is
untrusted input and goes through the shared sanitizing chain, which is
also what makes it inert on the public replay page. -->
<GfmMarkdown md={textOutput} noPadding />
{/if}
{:else}
{@render structuredOutput(result.output)}
<div class="flex flex-col w-full pt-1">
{#if trace.length > 0}
<AgentTrace entries={trace} {workspaceId} />
{/if}
<!-- What the run cost sits under what it produced: it is the footnote to the
answer, not the heading above it. -->
<div class="flex items-center gap-2 flex-wrap text-xs">
<div class={trace.length > 0 ? 'mt-4 pt-3 border-t border-border-light' : ''}>
<span class="text-2xs text-hint">Output</span>
<div class="mt-1">
{#if textOutput !== undefined}
{#if textOutput === ''}
<span class="text-tertiary text-xs">The agent returned no answer</span>
{:else}
<!-- A model writes this, and what it writes is steerable by whatever
reached its context — a user message, a tool's output. So it is
untrusted input and goes through the shared sanitizing chain, which
is also what makes it inert on the public replay page. -->
<GfmMarkdown md={textOutput} noPadding />
{/if}
{:else}
{@render structuredOutput(result.output)}
{/if}
</div>
</div>
<!-- What the run cost, as a footnote to what it produced. -->
<div class="flex items-center gap-2 flex-wrap text-xs mt-3">
{#if summary.toolCalls > 0}
<Badge color="blue">
{summary.toolCalls}
@@ -65,4 +86,5 @@
<Badge color="gray">{formatTokenCount(summary.cachedTokens)} cached</Badge>
{/if}
</div>
<div bind:this={anchor}></div>
</div>
@@ -1,7 +1,8 @@
<script lang="ts">
import { CircleX, Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { scrollPaneToEnd } from './agentScroll'
import {
advanceAgentStream,
emptyAgentStreamProgress,
@@ -32,32 +33,48 @@
})
let stream = $derived(progress.stream)
let anchor: HTMLElement | undefined = $state()
$effect(() => {
// Follow the text as it is written, the same way the finished run opens on
// its output: both put what you came for at the bottom.
stream.answer
stream.reasoning
stream.tools.length
scrollPaneToEnd(anchor)
})
</script>
<div class="flex flex-col gap-2 w-full">
{#if stream.tool}
<div
class="flex items-center gap-2 text-xs {stream.tool.success === false
? 'text-red-500'
: 'text-secondary'}"
>
{#if stream.tool.running}
<Loader2 class="animate-spin shrink-0" size={14} />
{:else if stream.tool.success === false}
<CircleX class="shrink-0" size={14} />
<!-- Deliberately the same order the finished run uses — what it did, then what it
produced — so the view does not rearrange itself when the result lands. -->
<div class="flex flex-col w-full pt-1">
{#each stream.tools as tool (tool.callId)}
<ChatCollapsibleCard
label={tool.name}
expanded={false}
toggleable={false}
shimmer={tool.running}
onToggle={() => {}}
labelClass={tool.success === false ? 'text-red-500' : ''}
/>
{/each}
<div class={stream.tools.length > 0 ? 'mt-4 pt-3 border-t border-border-light' : ''}>
<span class="text-2xs text-hint">Output</span>
<div class="mt-1">
<!-- Same sanitizing chain as the finished output: a partial answer is
written by the same model and is no more trusted for arriving in
pieces. -->
{#if stream.answer !== ''}
<GfmMarkdown md={stream.answer} noPadding />
{:else if stream.reasoning !== ''}
<!-- Reasoning arrives before the answer, so on its own it means the model
is still thinking rather than that this run has no answer. -->
<div class="text-secondary">
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
</div>
{/if}
<span class="font-mono truncate">{stream.tool.name}</span>
</div>
{/if}
<!-- Same sanitizing chain as the finished answer: a partial answer is written by
the same model and is no more trusted for arriving in pieces. -->
{#if stream.answer !== ''}
<GfmMarkdown md={stream.answer} noPadding />
{:else if stream.reasoning !== ''}
<!-- Reasoning arrives before the answer, so on its own it means the model is
still thinking rather than that this run has no answer. -->
<div class="text-secondary">
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
</div>
{/if}
</div>
<div bind:this={anchor}></div>
</div>
@@ -8,18 +8,15 @@
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { buildAgentTrace, type AgentTraceEntry } from './agentTrace'
import type { AgentMessage } from './aiAgentResult'
import type { AgentTraceEntry } from './agentTrace'
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
interface Props {
messages: AgentMessage[]
entries: AgentTraceEntry[]
workspaceId?: string
}
let { messages, workspaceId }: Props = $props()
const entries = $derived(buildAgentTrace(messages))
let { entries, workspaceId }: Props = $props()
let expanded = new SvelteSet<number>()
// A tool's own job holds what the envelope does not: its logs, how long it
@@ -20,7 +20,6 @@
Highlighter,
ArrowDownFromLine,
Bot,
ListTree,
Database,
Loader2
} from 'lucide-svelte'
@@ -165,8 +164,6 @@
growVertical = false
}: Props = $props()
let s3FileDisplayRawMode = $state(false)
/** Which half of an agent result is showing; JSON is `forceJson`, as for any kind. */
let agentView: 'answer' | 'trace' = $state('answer')
/** What a max-iterations failure got through before it gave up, if this is one.
* Empty for a run that failed before the worker tagged anything, and for one
* that predates the tags reaching this payload at all — in which case the
@@ -175,7 +172,7 @@
const messages = parseAgentErrorMessages(result)
if (!messages) return undefined
const entries = buildAgentTrace(messages)
return entries.length > 0 ? messages : undefined
return entries.length > 0 ? entries : undefined
})
// Build the image/PDF source URL for an S3 object. When `appPath` is set
@@ -825,26 +822,16 @@
bind:clientHeight={resultHeaderHeight}
>
{#if !hideAsJson && !['json', 's3object'].includes(resultKind ?? '') && typeof result === 'object'}<ToggleButtonGroup
selected={forceJson
? 'json'
: agentView === 'trace' && resultKind === 'aiagent'
? 'trace'
: resultKind?.startsWith('table-')
? 'table'
: 'pretty'}
selected={forceJson ? 'json' : resultKind?.startsWith('table-') ? 'table' : 'pretty'}
on:selected={(ev) => {
forceJson = ev.detail === 'json'
if (ev.detail === 'trace' || ev.detail === 'pretty') {
agentView = ev.detail === 'trace' ? 'trace' : 'answer'
}
}}
>
{#snippet children({ item })}
{#if ['table-col', 'table-row', 'table-row-object'].includes(resultKind ?? '')}
<ToggleButton size="sm" value="table" label="Table" icon={Table2} {item} />
{:else if resultKind === 'aiagent'}
<ToggleButton size="sm" value="pretty" label="Answer" icon={Bot} {item} />
<ToggleButton size="sm" value="trace" label="Trace" icon={ListTree} {item} />
<ToggleButton size="sm" value="pretty" label="Run" icon={Bot} {item} />
{:else}
<ToggleButton size="sm" value="pretty" label="Pretty" icon={Highlighter} {item} />
{/if}
@@ -1032,7 +1019,7 @@
rather than replacing it. -->
<div class="flex flex-col gap-1 pt-4 w-full min-w-0">
<span class="text-emphasis text-xs font-semibold">Trace</span>
<AgentTrace messages={agentErrorTrace} {workspaceId} />
<AgentTrace entries={agentErrorTrace} {workspaceId} />
</div>
{/if}
{#if !isTest && language === 'bun'}
@@ -1294,7 +1281,7 @@
{:else if !forceJson && resultKind === 'aiagent'}
{@const agentResult = parseAgentResult(result)}
{#if agentResult}
<AgentResultDisplay result={agentResult} view={agentView} {workspaceId}>
<AgentResultDisplay result={agentResult} {workspaceId}>
{#snippet structuredOutput(output)}
<DisplayResult
noControls
@@ -0,0 +1,23 @@
/**
* Scroll the pane an agent run is rendered in to its end.
*
* A run reads in the order it happened, so its output is the last thing in the
* list landing at the bottom is landing on the answer, and while it streams
* that is also where the text is arriving.
*
* Deliberately the nearest *scrollable* ancestor rather than `scrollIntoView`:
* the result viewer sits inside pages that scroll themselves, and yanking a
* whole run page to the middle because a step returned an agent result would be
* worse than not scrolling at all.
*/
export function scrollPaneToEnd(anchor: HTMLElement | undefined | null) {
let node = anchor?.parentElement
while (node && node !== document.body) {
const overflowY = getComputedStyle(node).overflowY
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
node.scrollTop = node.scrollHeight
return
}
node = node.parentElement
}
}
@@ -84,7 +84,9 @@ describe('agent stream', () => {
const { stream } = advanceAgentStream(events, emptyAgentStreamProgress())
expect(stream.answer).toBe('eu-central-1 is down')
expect(stream.reasoning).toBe('checking')
expect(stream.tool).toEqual({ name: 'query_metrics', running: false, success: true })
expect(stream.tools).toEqual([
{ callId: 'c1', name: 'query_metrics', running: false, success: true }
])
})
// The stream only grows, so each poll must fold in the new lines and re-read
@@ -107,13 +109,18 @@ describe('agent stream', () => {
it('marks a tool still running, and a failed one', () => {
const started = '{"type":"tool_execution","call_id":"c1","function_name":"fetch"}\n'
const running = advanceAgentStream(started, emptyAgentStreamProgress())
expect(running.stream.tool).toEqual({ name: 'fetch', running: true, success: undefined })
expect(running.stream.tools).toEqual([
{ callId: 'c1', name: 'fetch', running: true, success: undefined }
])
const failed = advanceAgentStream(
started +
'{"type":"tool_result","call_id":"c1","function_name":"fetch","result":"boom","success":false}\n',
running
)
expect(failed.stream.tool).toEqual({ name: 'fetch', running: false, success: false })
// One row for the call, not one per event about it.
expect(failed.stream.tools).toEqual([
{ callId: 'c1', name: 'fetch', running: false, success: false }
])
})
})
+22 -8
View File
@@ -187,18 +187,21 @@ export function summarizeAgentResult(result: AgentResult): AgentResultSummary {
}
}
export type AgentStreamTool = { callId: string; name: string; running: boolean; success?: boolean }
export type AgentStream = {
answer: string
reasoning: string
/** The most recent tool the run touched, so a stream that is mid-call says so. */
tool?: { name: string; running: boolean; success?: boolean }
/** Every tool the run has touched, in order, so a stream shows the same rows
* the finished trace will. */
tools: AgentStreamTool[]
}
/** How much of the stream has been folded in, so the next poll starts there. */
export type AgentStreamProgress = { consumed: number; stream: AgentStream }
export function emptyAgentStreamProgress(): AgentStreamProgress {
return { consumed: 0, stream: { answer: '', reasoning: '' } }
return { consumed: 0, stream: { answer: '', reasoning: '', tools: [] } }
}
/**
@@ -273,7 +276,7 @@ export function advanceAgentStream(
if (complete <= previous.consumed) {
return previous
}
const stream: AgentStream = { ...previous.stream }
const stream: AgentStream = { ...previous.stream, tools: [...previous.stream.tools] }
for (const line of raw.slice(previous.consumed, complete).split('\n')) {
if (line.trim() === '') {
continue
@@ -295,10 +298,21 @@ export function advanceAgentStream(
stream.answer = ''
stream.reasoning = ''
}
stream.tool = {
name: event.function_name,
running: event.type !== 'tool_result',
success: event.type === 'tool_result' ? event.success === true : undefined
// The same call is announced, then argued, then executed, then answered.
// Keyed on `call_id` so those four events are one row rather than four.
const callId = typeof event.call_id === 'string' ? event.call_id : event.function_name
const existing = stream.tools.find((t) => t.callId === callId)
const settled = event.type === 'tool_result'
if (existing) {
existing.running = !settled
existing.success = settled ? event.success === true : existing.success
} else {
stream.tools.push({
callId,
name: event.function_name,
running: !settled,
success: settled ? event.success === true : undefined
})
}
}
}