feat(web+nomos): fix chat streaming reactivity, unified activity timeline, tool cards in chat
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

- fix(web): Svelte 5 identity-based reactivity broke text_delta streaming —
  immutable message objects in all three chat handlers so text streams live
- feat(web): streaming cursor + inline status indicator merged into message flow
- feat(web): expandable inline tool call cards in chat thread
- feat(web): merge Plan + Event log into one backbone Activity timeline —
  filled status nodes, branch stubs, auto-scroll follow mode, per-session
  activityLog, compact for the rail
- fix(nomos): add X-Accel-Buffering:no to /chat SSE (proxy buffering)
- fix(nomos): plan step auto-close SQL param bug (store.go)
- polish: timestamps, role labels, code copy button, table overflow, min
  window size, delete AgentIndicator/ActivityTimeline dead code
This commit is contained in:
2026-07-21 07:49:02 +02:00
parent 55b93c59ef
commit ce34cfeac7
13 changed files with 742 additions and 392 deletions

View File

@@ -0,0 +1,350 @@
<script lang="ts">
import { slide } from 'svelte/transition'
import type { ActivityEntry } from '$lib/stores/activity'
import type { PlanStep } from '$lib/api'
import Spinner from './Spinner.svelte'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import PauseIcon from '@lucide/svelte/icons/pause'
import SlashIcon from '@lucide/svelte/icons/slash'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import MilestoneIcon from '@lucide/svelte/icons/milestone'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
import FlagIcon from '@lucide/svelte/icons/flag'
// Merged plan + activity timeline, designed for the narrow rail:
// - 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
// - plan steps are filled status nodes ON the backbone; their tool calls
// branch off with horizontal stubs
// - flat entries (goal/knowledge/complete/orphan tools) are milestone
// 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)
let { entries, planSteps: steps, streaming = false }: {
entries: ActivityEntry[]
planSteps: PlanStep[]
streaming?: boolean
} = $props()
// Explicit user toggles only — default open state derives from step status
// (running = expanded, everything else = collapsed) so a step collapses
// itself the moment it finishes unless the operator pinned it open.
let stepToggles = $state(new Map<string, boolean>())
let expandedTools = $state(new Set<string>())
function stepOpen(step: PlanStep): boolean {
return stepToggles.get(step.id) ?? step.status === 'running'
}
function toggleStep(step: PlanStep) {
stepToggles.set(step.id, !stepOpen(step))
stepToggles = new Map(stepToggles)
}
function toggleTool(id: string) {
if (expandedTools.has(id)) expandedTools.delete(id)
else expandedTools.add(id)
expandedTools = new Set(expandedTools)
}
// ── Timeline model ────────────────────────────────────────────────────────
type TLItem =
| { kind: 'step'; step: PlanStep; tools: ActivityEntry[]; ts: number }
| { kind: 'entry'; entry: ActivityEntry; ts: number }
const items = $derived.by<TLItem[]>(() => {
const stepIds = new Set(steps.map((s) => s.id))
const out: TLItem[] = []
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.
if (steps.length > 0) {
out.push({ kind: 'step', step: s, tools: [], ts: Number.MAX_SAFE_INTEGER - s.seq })
}
continue
}
const tools = entries.filter(
(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)
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
out.push({ kind: 'step', step: s, tools, ts })
}
for (const e of entries) {
const isTool = e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error'
if (isTool && e.stepSeq != null) continue // nested under its step
if (!isTool && stepIds.has(e.id)) continue // rendered as step node
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
}
out.sort((a, b) => a.ts - b.ts)
return out
})
// ── Current activity + auto-scroll ────────────────────────────────────────
const currentId = $derived.by<string | null>(() => {
const runningTool = entries.find((e) => e.type === 'tool_running' && e.status === 'running')
if (runningTool) return runningTool.id
const runningStep = steps.find((s) => s.status === 'running')
if (runningStep) return runningStep.id
return null
})
let container = $state<HTMLDivElement | null>(null)
let follow = $state(true)
function onScroll() {
if (!container) return
follow = container.scrollHeight - container.scrollTop - container.clientHeight < 80
}
// A new turn re-engages follow mode even if the operator had scrolled up.
let wasStreaming = $state(false)
$effect(() => {
if (streaming && !wasStreaming) follow = true
wasStreaming = streaming
})
// Scroll to the current step/tool whenever it changes (smooth) or when new
// entries land while following (instant, to avoid scroll-queue jank).
$effect(() => {
if (!currentId || !follow || !container) return
container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
let lastEntryCount = 0
$effect(() => {
const n = entries.length
if (n === lastEntryCount) return
lastEntryCount = n
if (!follow || !container) return
const target = currentId
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
: null
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
else container.scrollTop = container.scrollHeight
})
// ── Presentation helpers ──────────────────────────────────────────────────
// Segment geometry: the backbone's center runs at x=17.5px (node center:
// px-3 = 11.25px at the app's 15px root font-size + half of the 13px node),
// so the 1px line sits at left-17px. Each item's segment spans its full
// height so tools inside an expanded step stay on the line; first/last
// items clip theirs to their node/tool centers so the line never dangles
// past the timeline's ends.
function segClass(status: string, isFirst: boolean, isLast: boolean, expandedWithTools: boolean): string {
let color = 'bg-border'
if (status === 'done') color = 'bg-primary/60'
else if (status === 'running') color = 'bg-primary/40'
else if (status === 'failed') color = 'bg-destructive/40'
if (isFirst && isLast) return `${color} top-[13px] h-0`
if (isFirst) return `${color} top-[13px] bottom-0`
if (isLast && expandedWithTools) return `${color} top-0 bottom-[11px]`
if (isLast) return `${color} top-0 bottom-[calc(100%-13px)]`
return `${color} top-0 bottom-0`
}
function entryIcon(entry: ActivityEntry) {
switch (entry.type) {
case 'goal': return MilestoneIcon
case 'knowledge': return SparklesIcon
case 'complete': return FlagIcon
case 'question': return HelpCircleIcon
default: return WrenchIcon
}
}
function hhmm(ts: number): string {
if (!ts || ts > Number.MAX_SAFE_INTEGER - 1000) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function hhmmss(ts: number): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function prettyPrint(raw: string): string {
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch { return raw }
}
</script>
<div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
{#if items.length === 0}
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
<svg viewBox="0 0 64 110" class="h-14 w-auto text-muted-foreground/40" fill="none">
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
<circle cx="32" cy="22" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="55" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
<animate attributeName="r" values="4;11;4" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.6;0;0.6" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="88" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
</circle>
</svg>
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
</div>
{:else}
<div class="flex flex-col py-1">
{#each items as item, i (item.kind === 'step' ? item.step.id : item.entry.id)}
{@const isFirst = i === 0}
{@const isLast = i === items.length - 1}
{#if item.kind === 'step'}
{@const st = item.step.status}
{@const open = stepOpen(item.step)}
{@const hasDetail = !!item.step.detail?.trim()}
{@const expandable = item.tools.length > 0 || hasDetail}
{@const expandedWithTools = open && item.tools.length > 0}
<!-- Step node on the backbone -->
<div class="relative" data-tl-id={item.step.id}>
<span class="pointer-events-none absolute left-[17px] w-px {segClass(st, isFirst, isLast, expandedWithTools)}" aria-hidden="true"></span>
<button
type="button"
class="relative flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs {expandable ? 'cursor-pointer hover:bg-muted/30' : 'cursor-default'} {st === 'running' ? 'bg-primary/5' : ''}"
onclick={() => expandable && toggleStep(item.step)}
aria-expanded={open}
disabled={!expandable}
>
<!-- Filled status node -->
<span class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
{st === 'done' ? 'bg-primary'
: st === 'running' ? 'bg-background'
: st === 'failed' ? 'bg-destructive'
: st === 'blocked' ? 'bg-warning/25 border border-warning'
: st === 'skipped' || st === 'replaced' ? 'bg-muted'
: 'bg-background border border-muted-foreground/40'}">
{#if st === 'running'}
<span class="absolute -inset-0.5 animate-ping rounded-full bg-primary/30"></span>
<Spinner class="relative size-3.5 text-primary" />
{:else if st === 'done'}
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
{:else if st === 'failed'}
<XIcon class="size-2.5 text-destructive-foreground" strokeWidth={3.5} />
{:else if st === 'blocked'}
<PauseIcon class="size-2 text-warning" strokeWidth={3} />
{:else if st === 'skipped' || st === 'replaced'}
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
{/if}
</span>
<span title={item.step.title} class="min-w-0 flex-1 leading-snug {open ? 'whitespace-normal' : 'truncate'} {st === 'done' ? 'text-muted-foreground' : st === 'running' ? 'font-medium text-foreground' : 'text-muted-foreground'}">
{item.step.title}
</span>
{#if hhmm(item.ts)}
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60">{hhmm(item.ts)}</span>
{/if}
{#if item.tools.length > 0}
<span class="shrink-0 text-muted-foreground/60">
{#if open}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
</span>
{/if}
</button>
{#if expandedWithTools}
<div transition:slide={{ duration: 150 }} class="flex flex-col">
{#each item.tools as tool (tool.id)}
{@const tOpen = expandedTools.has(tool.id)}
<div class="relative" data-tl-id={tool.id}>
<!-- Branch stub: backbone → tool -->
<span class="pointer-events-none absolute left-[17px] top-[9.5px] h-px w-[17px] {tool.status === 'failed' ? 'bg-destructive/40' : 'bg-border'}" aria-hidden="true"></span>
<button
type="button"
class="flex w-full items-center gap-1.5 py-1 pl-9 pr-3 text-left text-[11px] {(tool.args || tool.detail) ? 'cursor-pointer hover:bg-muted/20' : 'cursor-default'}"
onclick={() => (tool.args || tool.detail) && toggleTool(tool.id)}
>
<span class="flex size-3 shrink-0 items-center justify-center">
{#if tool.status === 'running'}
<Spinner class="size-2.5 text-primary" />
{:else if tool.status === 'failed'}
<XIcon class="size-2.5 text-destructive" strokeWidth={3.5} />
{:else}
<CheckIcon class="size-2.5 text-primary/70" strokeWidth={3.5} />
{/if}
</span>
<span title={tool.description} class="min-w-0 flex-1 truncate leading-snug {tool.status === 'done' ? 'text-muted-foreground' : tool.status === 'failed' ? 'text-destructive' : 'text-foreground/80'}">
{tool.description}
</span>
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50">{hhmm(tool.timestamp)}</span>
</button>
{#if tOpen}
<div transition:slide={{ duration: 120 }} class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3">
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
<span class="capitalize">{tool.status}</span>
<span aria-hidden="true">·</span>
<span>{hhmmss(tool.timestamp)}</span>
{#if tool.toolName}<span aria-hidden="true">·</span><code class="font-mono">{tool.toolName}</code>{/if}
</div>
{#if tool.args}
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(tool.args)}</pre>
{/if}
{#if tool.detail}
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {tool.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}">{prettyPrint(tool.detail)}</pre>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Flat entry: milestone marker on the backbone -->
{@const e = item.entry}
{@const Icon = entryIcon(e)}
{@const eOpen = expandedTools.has(e.id)}
<div class="relative" data-tl-id={e.id}>
<span class="pointer-events-none absolute left-[17px] w-px {segClass(e.status, isFirst, isLast, false)}" aria-hidden="true"></span>
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] {(e.args || e.detail) ? 'cursor-pointer hover:bg-muted/30' : 'cursor-default'}"
onclick={() => (e.args || e.detail) && toggleTool(e.id)}
>
<span class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background
{e.status === 'failed' ? 'border-destructive text-destructive' : e.status === 'running' ? 'border-primary text-primary' : 'border-border text-primary'}">
{#if e.status === 'running'}
<Spinner class="size-2.5" />
{:else if e.status === 'failed'}
<XIcon class="size-2" strokeWidth={3.5} />
{:else}
<Icon class="size-2" strokeWidth={2.5} />
{/if}
</span>
<span title={e.description} class="min-w-0 flex-1 truncate leading-snug {e.status === 'done' ? 'text-muted-foreground' : 'text-foreground/80'}">
{e.description}
</span>
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60">{hhmm(e.timestamp)}</span>
</button>
{#if eOpen}
<div transition:slide={{ duration: 120 }} class="flex flex-col gap-1 pb-1.5 pl-9 pr-3">
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
<span class="capitalize">{e.status}</span>
<span aria-hidden="true">·</span>
<span>{hhmmss(e.timestamp)}</span>
{#if e.toolName}<span aria-hidden="true">·</span><code class="font-mono">{e.toolName}</code>{/if}
</div>
{#if e.args}
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(e.args)}</pre>
{/if}
{#if e.detail}
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {e.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}">{prettyPrint(e.detail)}</pre>
{/if}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>