From 8f440c5ad50b913a1a3085ef84cb07a2f442f42a Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 26 Jul 2026 13:06:01 +0200 Subject: [PATCH] feat(web): collapse chat tool calls into one agent trace, reverse the activity rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat rendered one card per tool call, so a 20-call turn buried the answer under 20 stacked cards. Merge them with the "thinking" indicator into a single collapsible strip above the answer: - collapsed: the live activity while running, a count once finished - expanded: the turn's work in humanized language (reuses the activity log's toolActivityLabel, so ten identical "run · target: host:strong" rows now read as what they actually did) - per row: the raw args/result, one more click in Also flip the Activity rail to newest-first with the current step on top: - follow-mode/auto-scroll re-anchored to the top to match, or it would jump to the oldest entry on every new event - pending plan steps park at the tail rather than sorting above the running step and pushing it off the top; the goal anchors the bottom Co-Authored-By: Claude Opus 4.8 --- web/src/lib/components/AgentTrace.svelte | 115 ++++++++++++++++++ web/src/lib/components/ChatThread.svelte | 46 ++++--- web/src/lib/components/ToolCallCard.svelte | 72 +++++------ web/src/lib/components/UnifiedTimeline.svelte | 32 +++-- web/src/lib/stores/activity.ts | 5 +- 5 files changed, 203 insertions(+), 67 deletions(-) create mode 100644 web/src/lib/components/AgentTrace.svelte diff --git a/web/src/lib/components/AgentTrace.svelte b/web/src/lib/components/AgentTrace.svelte new file mode 100644 index 0000000..d6a17cc --- /dev/null +++ b/web/src/lib/components/AgentTrace.svelte @@ -0,0 +1,115 @@ + + +
+ + + {#if expanded} +
+ {#if count > 0} + {#each tools as tool (tool.id)} + + {/each} + {:else} +

Nothing recorded for this turn yet.

+ {/if} +
+ {/if} +
+ + diff --git a/web/src/lib/components/ChatThread.svelte b/web/src/lib/components/ChatThread.svelte index 2f75410..5837de1 100644 --- a/web/src/lib/components/ChatThread.svelte +++ b/web/src/lib/components/ChatThread.svelte @@ -9,12 +9,9 @@ import type { Readable } from 'svelte/store' import { Button } from '$lib/components/ui/button' import { Textarea } from '$lib/components/ui/textarea' - import Spinner from './Spinner.svelte' - import ToolCallCard from './ToolCallCard.svelte' + import AgentTrace from './AgentTrace.svelte' import OperatorQuestion from './OperatorQuestion.svelte' import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left' - import CheckIcon from '@lucide/svelte/icons/check' - import XIcon from '@lucide/svelte/icons/x' import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw' import SquareIcon from '@lucide/svelte/icons/square' import { marked } from 'marked' @@ -217,6 +214,16 @@
{msg.text}
{:else} + {@const isLast = idx === messages.length - 1} + {@const traceStatus = !isLast + ? 'idle' + : error + ? 'error' + : streaming + ? 'running' + : indicatorDone + ? 'done' + : 'idle'}
Nomos @@ -224,34 +231,25 @@ {formatTime(msg.created_at)} {/if}
+ + {#if msg.tools.length > 0 || traceStatus !== 'idle'} + + {/if} {#if msg.text}
{@html render(msg.text)} - {#if idx === messages.length - 1 && streaming} + {#if isLast && streaming} {/if}
{/if} - {#if msg.tools.length > 0} -
- {#each msg.tools as tool (tool.id)} - - {/each} -
- {/if} - {#if idx === messages.length - 1 && msg.text === '' && (streaming || indicatorDone || error)} -
- {#if error} - - {:else if indicatorDone} - - {:else} - - {/if} - {indicatorLabel} -
- {/if}
{/if} diff --git a/web/src/lib/components/ToolCallCard.svelte b/web/src/lib/components/ToolCallCard.svelte index 7c39091..1e677a6 100644 --- a/web/src/lib/components/ToolCallCard.svelte +++ b/web/src/lib/components/ToolCallCard.svelte @@ -1,6 +1,11 @@ -
+
{#if expanded} -
+
{#if tool.args}
-
Args
-
{JSON.stringify(tool.args, null, 2)}
+
Args
+
{JSON.stringify(tool.args, null, 2)}
{/if} {#if tool.result !== undefined && tool.result !== null}
-
Result
-
{JSON.stringify(tool.result, null, 2)}
+
Result
+
{JSON.stringify(tool.result, null, 2)}
{/if} {#if tool.error}
-
Error
-
{tool.error}
+
Error
+
{tool.error}
{/if}
{/if}
- - diff --git a/web/src/lib/components/UnifiedTimeline.svelte b/web/src/lib/components/UnifiedTimeline.svelte index 793d080..07099bd 100644 --- a/web/src/lib/components/UnifiedTimeline.svelte +++ b/web/src/lib/components/UnifiedTimeline.svelte @@ -16,6 +16,9 @@ import FlagIcon from '@lucide/svelte/icons/flag' // Merged plan + activity timeline, designed for the narrow rail: + // - ordered newest-first: what the agent is doing right now is at the top, + // history flows downward, and the goal sits at the bottom where the task + // began (see the sort in `items`) // - one continuous vertical "backbone"; every item owns a segment of it, // colored by state (done = filled primary, running = faint primary, // pending/future = muted) so the line visibly fills in as work completes @@ -25,7 +28,8 @@ // markers on the same backbone // - the running step auto-expands and the view auto-scrolls to keep the // current step visible while the agent works (follow mode disengages if - // the operator scrolls up, re-engages when streaming starts again) + // the operator scrolls down into history, re-engages when streaming + // starts again) let { entries, planSteps: steps, streaming = false }: { entries: ActivityEntry[] planSteps: PlanStep[] @@ -63,9 +67,10 @@ for (const s of steps) { if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) { // Pending steps with no activity yet still show on the timeline so - // the operator sees what's coming — but only if a plan exists. + // the operator sees what's coming — but only if a plan exists. ts 0 + // parks them at the tail of the newest-first sort below (see there). if (steps.length > 0) { - out.push({ kind: 'step', step: s, tools: [], ts: Number.MAX_SAFE_INTEGER - s.seq }) + out.push({ kind: 'step', step: s, tools: [], ts: 0 }) } continue } @@ -73,8 +78,11 @@ (e) => e.stepSeq === s.seq && (e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error') ) const stepEntry = entries.find((e) => e.id === s.id) + // Timed from the step's own entry, else its earliest tool — so a step + // is placed by when it started, not by its latest activity. const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now() - out.push({ kind: 'step', step: s, tools, ts }) + // Tools inside a step run newest-first too, matching the outer order. + out.push({ kind: 'step', step: s, tools: [...tools].reverse(), ts }) } for (const e of entries) { @@ -84,7 +92,15 @@ out.push({ kind: 'entry', entry: e, ts: e.timestamp }) } - out.sort((a, b) => a.ts - b.ts) + // Newest first: whatever the agent is doing right now sits at the top of + // the rail, with history flowing downward. The two ts-0 groups fall to + // the bottom for free, which is where both belong in this order: the goal + // (timestamp 0 — where the task started) and not-yet-run plan steps. + // Sorting the latter by their future position would put them *above* the + // running step and push it off the top, which is exactly what this + // ordering exists to prevent. Array.sort is stable, so each group keeps + // its insertion order (plan steps in seq order). + out.sort((a, b) => b.ts - a.ts) return out }) @@ -100,9 +116,11 @@ let container = $state(null) let follow = $state(true) + // Newest-first, so "following the agent" means being parked at the top — + // the mirror of the bottom-anchored follow this had when it ran oldest-first. function onScroll() { if (!container) return - follow = container.scrollHeight - container.scrollTop - container.clientHeight < 80 + follow = container.scrollTop < 80 } // A new turn re-engages follow mode even if the operator had scrolled up. @@ -129,7 +147,7 @@ ? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`) : null if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' }) - else container.scrollTop = container.scrollHeight + else container.scrollTop = 0 }) // ── Presentation helpers ────────────────────────────────────────────────── diff --git a/web/src/lib/stores/activity.ts b/web/src/lib/stores/activity.ts index 73c78ed..f8af4b7 100644 --- a/web/src/lib/stores/activity.ts +++ b/web/src/lib/stores/activity.ts @@ -185,7 +185,10 @@ export function activityLogFor(sessionId: string): Readable { return derived([chat.messages, ws.planSteps, task], ([$msgs, $steps, $task]) => computeActivityLog($msgs, $steps, $task)) } -function toolActivityLabel(t: ToolCallResult): string { +// Humanized, past/present-tense description of what a tool call is doing +// ("Check execution", "Research: …") rather than its raw wire name. Exported +// so the chat's agent trace can read as a thinking log instead of an API log. +export function toolActivityLabel(t: ToolCallResult): string { const args = t.args ?? {} const str = (v: unknown): string => typeof v === 'string' ? v : '' switch (t.name) {