feat(web): collapse chat tool calls into one agent trace, reverse the activity rail
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:06:01 +02:00
parent 4e4e2c169c
commit 8f440c5ad5
5 changed files with 203 additions and 67 deletions

View File

@@ -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<HTMLDivElement | null>(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 ──────────────────────────────────────────────────