Files
windmill/frontend/src/lib/components/AgentStreamDisplay.svelte
T
hugocasaandClaude Opus 5 a08992834d feat: render an AI agent result as its answer, not as raw JSON (#11051)
* feat: render an AI agent result as its answer, not as raw JSON

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: sanitize agent markdown through the shared plugin chain

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: fold agent stream events incrementally per poll

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: separate the agent meta line from the result toggle group

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add a transcript view of an agent run's conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: retire AIAgentLogViewer in favour of the transcript

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: show the partial transcript a max-iterations failure carries

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: move the agent meta line and system prompt below the conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: show an agent run as what it did, not as a conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: name the agent run breakdown a trace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): label the save-as-agent form fields per the guidelines

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): reuse the resource form's path and description fields

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): keep the action tags on a max-iterations failure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): keep offline replay inert and the streamed answer to one turn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): reset the streamed answer on providers that skip tool_call

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry the event type narrowing through the stream parser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: survive a malformed message rather than take the viewer down

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: coerce agent messages once at the parse boundary

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep every streamed turn instead of dropping the narration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: share the chat divider and drop the unsafe run auto-scroll

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: give the labelled divider a border colour and the standard pretty icon

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: pad the agent run below its badges as well as above

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: follow a streaming run's pane without moving the page

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: share the chat's stick-to-bottom mechanics with the agent run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the answer's citations and end a turn's reasoning with the turn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: read the agent stream through the windmill-chat sdk parser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show an agent run's thinking instead of falling back to raw json

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a stream the fold cannot use from claiming the run pane

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: identify an agent run by more than the job id the replay withholds

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop the tests and comment lines that were not earning their place

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a streamed turn's text shifting when a tool call closes it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:38:46 +02:00

112 lines
3.7 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { runPane } from './agentScroll'
import { createBottomSticker } from './stickToBottom'
import {
advanceAgentStream,
emptyAgentStreamProgress,
type AgentStreamProgress
} from './aiAgentResult'
interface Props {
/** The raw `result_stream` buffer, which only ever grows for a given run. */
raw: string
/** Identifies the run, so a viewer reused for another one starts over. */
streamKey?: string
}
let { raw, streamKey }: Props = $props()
let progress: AgentStreamProgress & { key?: string } = $state(emptyAgentStreamProgress())
$effect(() => {
raw
streamKey
untrack(() => {
// A shorter buffer than what was already folded in cannot be a longer
// version of the same stream, so treat it as a different one.
const continues = progress.key === streamKey && raw.length >= progress.consumed
const base = continues ? progress : emptyAgentStreamProgress()
progress = { ...advanceAgentStream(raw, base), key: streamKey }
if (!continues) {
// Another run, so the reader's decision to stop following the previous
// one does not carry over: this one starts at its latest output.
following = true
}
})
})
let stream = $derived(progress.stream)
let anchor: HTMLElement | undefined = $state()
// Carry the reader along as the text is written, unless they have scrolled up
// to read something — then the pane is theirs until they come back to the end.
let following = true
const sticker = createBottomSticker()
// The listener goes on the pane, not on this element: a scroll event fires on
// whatever actually scrolled and does not bubble, so a handler here would never
// run and the reader would be dragged back on every poll.
$effect(() => {
const pane = runPane(anchor)
if (!pane) {
return
}
const onScroll = () => {
if (!sticker.isOwnScroll()) {
following = sticker.isAtEnd(pane)
}
}
pane.addEventListener('scroll', onScroll, { passive: true })
return () => pane.removeEventListener('scroll', onScroll)
})
$effect(() => {
stream.current
stream.reasoning
stream.entries.length
if (following) {
sticker.scrollToEnd(runPane(anchor))
}
})
</script>
<!-- One gap on the container rather than margins per child, and the same gap the
finished trace uses: a turn's text must not shift when a tool call turns it from
the live text into a row. The text at the bottom is deliberately unlabelled — a
turn that goes on to call a tool was narration, and the run's end settles which. -->
<div bind:this={anchor} class="flex flex-col w-full py-3 gap-1">
{#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)}
{#if entry.kind === 'tool'}
<ChatCollapsibleCard
label={entry.name}
expanded={false}
toggleable={false}
shimmer={entry.running}
onToggle={() => {}}
labelClass={entry.success === false ? 'text-red-500' : ''}
/>
{:else}
<div>
<GfmMarkdown md={entry.content} noPadding />
</div>
{/if}
{/each}
{#if stream.current !== ''}
<div>
<!-- Same sanitizing chain as a finished output: a partial answer is written
by the same model and is no more trusted for arriving in pieces. -->
<GfmMarkdown md={stream.current} noPadding />
</div>
{:else if stream.reasoning !== ''}
<!-- Reasoning arrives before the text, so on its own it means the model is
still thinking rather than that this run has nothing to say. -->
<div class="text-secondary">
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
</div>
{/if}
</div>