Files
windmill/frontend/src/lib/components/FlowLogViewerWrapper.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

192 lines
4.8 KiB
Svelte

<script lang="ts">
import type { Job } from '$lib/gen'
import type { DurationStatus, GraphModuleState } from './graph'
import FlowLogViewer from './FlowLogViewer.svelte'
import { TimelineCompute } from '$lib/timelineCompute.svelte'
import { onMount, untrack } from 'svelte'
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
import { readFieldsRecursively } from '$lib/utils'
import type { NavigationChain } from '$lib/keyboardChain'
import OnChange from './common/OnChange.svelte'
interface Props {
job: Partial<Job>
localDurationStatuses?: Record<string, DurationStatus>
workspaceId: string | undefined
render: boolean
localModuleStates: Record<string, GraphModuleState>
onSelectedIteration?: (
detail:
| { id: string; index: number; manuallySet: true; moduleId: string }
| { manuallySet: false; moduleId: string }
) => Promise<void>
}
let {
job,
localModuleStates,
localDurationStatuses,
workspaceId,
render,
onSelectedIteration
}: Props = $props()
// State for tracking expanded rows - using Record to allow explicit control
let expandedRows: Record<string, boolean> = $state({})
let allExpanded = $state(false)
let showResultsInputs = $state(true)
// Keyboard navigation state - incremental like expandedRows
let currentId = $state<string | null>('flow-root')
let navigationChain = $state<NavigationChain>({})
// Timeline state
let timelineCompute = $state<TimelineCompute | undefined>(undefined)
onMount(() => {
timelineCompute = new TimelineCompute(
modules.map((m) => m.id),
localDurationStatuses ?? {},
job.type === 'CompletedJob'
)
return () => {
timelineCompute?.destroy()
}
})
// Derived timeline values
const timelineMin = $derived(timelineCompute?.min ?? undefined)
const timelineTotal = $derived(timelineCompute?.total ?? undefined)
const timelineItems = $derived(timelineCompute?.items ?? undefined)
const timelineNow = $derived(timelineCompute?.now ?? Date.now())
let moduleTracker = new ChangeTracker($state.snapshot(untrack(() => job).raw_flow?.modules ?? []))
$effect(() => {
readFieldsRecursively(job.raw_flow?.modules ?? [])
untrack(() => moduleTracker.track($state.snapshot(job.raw_flow?.modules ?? [])))
})
let modules = $derived.by(() => {
moduleTracker.counter
return untrack(() => job.raw_flow?.modules ?? [])
})
function toggleExpanded(id: string) {
// If not in record, use opposite of allExpanded as new state
// If in record, toggle the current state
const currentState = expandedRows[id] ?? allExpanded
expandedRows[id] = !currentState
}
function getSelectedIteration(stepId: string): number {
return localModuleStates[stepId]?.selectedForloopIndex ?? 0
}
function toggleExpandAll() {
allExpanded = !allExpanded
expandedRows = {}
}
// Keyboard event handler using navigation links
function handleKeydown(event: KeyboardEvent) {
if (!currentId && job.raw_flow?.modules) {
currentId = 'flow-root'
} else if (!currentId) {
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
const downId = navigationChain[currentId]?.downId
if (downId) {
currentId = downId
}
break
case 'ArrowUp':
event.preventDefault()
const upId = navigationChain[currentId]?.upId
if (upId) {
currentId = upId
}
break
case 'Enter':
event.preventDefault()
toggleExpanded(currentId)
break
}
}
function select(id: string) {
currentId = id
}
let timelineAvailableWidths = $state<Record<string, number>>({})
let lastJobId: string | undefined = $state(untrack(() => job).id)
const timelinelWidth = $derived.by(() => {
const widths = Object.values(timelineAvailableWidths)
return widths.length > 0 ? Math.max(Math.min(...widths) - 12, 0) : 0
})
function updateJobId() {
if (job.id !== lastJobId) {
lastJobId = job.id
navigationChain = {}
timelineAvailableWidths = {}
currentId = 'flow-root'
showResultsInputs = true
timelineCompute?.reset()
}
}
$effect.pre(() => {
job.id
untrack(() => {
job.id && updateJobId()
})
})
</script>
<OnChange
key={localDurationStatuses}
onChange={() => {
timelineCompute?.updateInputs(
modules.map((m) => m.id),
localDurationStatuses ?? {},
job.type === 'CompletedJob'
)
}}
/>
<div
class="w-full rounded-md overflow-hidden border focus:border-gray-400 dark:focus:border-gray-400"
role="tree"
tabindex="0"
onkeydown={handleKeydown}
>
<FlowLogViewer
{modules}
{localModuleStates}
rootJob={job}
{expandedRows}
{allExpanded}
{showResultsInputs}
{toggleExpanded}
{toggleExpandAll}
{onSelectedIteration}
{workspaceId}
{render}
{getSelectedIteration}
flowId="root"
{currentId}
bind:navigationChain
{select}
{timelineMin}
{timelineTotal}
{timelineItems}
{timelineNow}
bind:timelineAvailableWidths
{timelinelWidth}
/>
</div>