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

@@ -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 @@
</div>
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
{:else}
{@const isLast = idx === messages.length - 1}
{@const traceStatus = !isLast
? 'idle'
: error
? 'error'
: streaming
? 'running'
: indicatorDone
? 'done'
: 'idle'}
<div class="flex w-full flex-col gap-2">
<div class="flex items-baseline gap-2 px-1">
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
@@ -224,34 +231,25 @@
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
{/if}
</div>
<!-- The working trace sits above the answer: it's what happened
first, and collapsed it keeps a long tool run from burying
the text below it. -->
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
<AgentTrace
tools={msg.tools}
status={traceStatus}
label={traceStatus === 'idle' ? null : indicatorLabel}
/>
{/if}
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
{#if idx === messages.length - 1 && streaming}
{#if isLast && streaming}
<span class="stream-cursor" aria-hidden="true"></span>
{/if}
</div>
{/if}
{#if msg.tools.length > 0}
<div class="flex flex-col gap-1.5">
{#each msg.tools as tool (tool.id)}
<ToolCallCard {tool} />
{/each}
</div>
{/if}
{#if idx === messages.length - 1 && msg.text === '' && (streaming || indicatorDone || error)}
<div class="flex items-center gap-2 py-1 text-xs {error ? 'text-destructive' : indicatorDone ? 'text-primary' : 'text-muted-foreground'}">
{#if error}
<XIcon class="size-3 shrink-0" />
{:else if indicatorDone}
<CheckIcon class="size-3 shrink-0" />
{:else}
<Spinner class="size-3 shrink-0 text-primary" />
{/if}
<span>{indicatorLabel}</span>
</div>
{/if}
</div>
{/if}
</div>