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,85 @@
<script lang="ts">
import { Check, ChevronRight, Loader2, Wrench, X } from '@lucide/svelte'
import type { ToolCallResult } from '$lib/types'
let { tool }: { tool: ToolCallResult } = $props()
let expanded = $state(false)
const status = $derived.by(() => {
if (tool.type === 'tool_use') return 'running'
if (tool.error) return 'error'
return 'done'
})
const statusColor = $derived.by(() => {
if (status === 'running') return 'text-primary'
if (status === 'error') return 'text-destructive'
return 'text-success/60'
})
const argsSummary = $derived.by(() => {
if (!tool.args) return ''
const entries = Object.entries(tool.args)
if (entries.length === 0) return ''
const first = entries[0]
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
})
</script>
<div class="tool-card rounded-lg border border-border/60 bg-card/40 overflow-hidden transition-all">
<button
class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-muted/40 transition-colors"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
>
<ChevronRight class="size-3 shrink-0 text-muted-foreground transition-transform {expanded ? 'rotate-90' : ''}" />
<Wrench class="size-3.5 shrink-0 text-muted-foreground" />
<span class="font-mono text-xs font-medium text-foreground/80">{tool.name}</span>
{#if argsSummary}
<span class="ml-1 truncate text-[11px] text-muted-foreground/70">{argsSummary}</span>
{/if}
<span class="ml-auto shrink-0 {statusColor}">
{#if status === 'running'}
<Loader2 class="size-3.5 animate-spin" />
{:else if status === 'error'}
<X class="size-3.5" />
{:else}
<Check class="size-3.5" />
{/if}
</span>
</button>
{#if expanded}
<div class="border-t border-border/40 px-3 py-2 space-y-2">
{#if tool.args}
<div>
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Args</div>
<pre class="tool-pre rounded-md bg-muted/60 p-2 text-[11px] overflow-x-auto max-h-48">{JSON.stringify(tool.args, null, 2)}</pre>
</div>
{/if}
{#if tool.result !== undefined && tool.result !== null}
<div>
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Result</div>
<pre class="tool-pre rounded-md bg-muted/60 p-2 text-[11px] overflow-x-auto max-h-48">{JSON.stringify(tool.result, null, 2)}</pre>
</div>
{/if}
{#if tool.error}
<div>
<div class="text-[10px] font-semibold uppercase tracking-wider text-destructive mb-1">Error</div>
<pre class="tool-pre rounded-md bg-destructive/5 border border-destructive/20 p-2 text-[11px] text-destructive overflow-x-auto max-h-48">{tool.error}</pre>
</div>
{/if}
</div>
{/if}
</div>
<style>
.tool-card {
animation: tool-in 0.2s ease-out;
}
@keyframes tool-in {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
</style>