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

@@ -5,11 +5,15 @@
// through this, so the message-bubble/markdown styling lives in one place
// instead of being copy-pasted between the two.
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { activityLog } from '$lib/stores/activity'
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
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 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'
@@ -26,7 +30,8 @@
onCancel,
onReconnect,
onDismissError,
suggestions = []
suggestions = [],
activityLog: activityLogProp = activityLog
}: {
messages: ChatMessage[]
streaming: boolean
@@ -38,6 +43,7 @@
onReconnect: () => void
onDismissError: (id: string) => void
suggestions?: string[]
activityLog?: Readable<ActivityEntry[]>
} = $props()
let input = $state('')
@@ -45,6 +51,26 @@
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
let indicatorDone = $state(false)
let wasStreaming = $state(false)
$effect(() => {
if (streaming) { indicatorDone = false; wasStreaming = true }
if (!streaming && wasStreaming) {
indicatorDone = true
const t = setTimeout(() => { indicatorDone = false; wasStreaming = false }, 3000)
return () => clearTimeout(t)
}
})
const indicatorLabel = $derived.by(() => {
if (error) return error
if (!streaming && indicatorDone) return 'Done'
const running = $activityLogProp.find((e: ActivityEntry) => e.status === 'running')
if (running) return running.description
return 'Agent is thinking…'
})
// Resizable input area — drag the splitter above it to grow the textarea,
// capped so it can't swallow the whole thread. Both the minimum and the
// default are exactly one line: measured from the textarea's own
@@ -102,7 +128,26 @@
})
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
const renderer = new marked.Renderer()
renderer.code = function ({ text, lang }) {
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `<div class="code-block-wrapper relative group"><pre><code class="language-${lang || 'plaintext'}">${escaped}</code></pre><button class="code-copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)" title="Copy" aria-label="Copy code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>`
}
renderer.table = function (token) {
const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
const body = token.rows.map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`).join('')
return `<div class="table-wrapper"><table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`
}
return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
}
function formatTime(iso: string): string {
try {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} catch {
return ''
}
}
function submit() {
@@ -149,27 +194,56 @@
</div>
{/if}
{#each messages as msg (msg.id)}
{#each messages as msg, idx (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="flex items-baseline gap-2 px-1">
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
{#if msg.created_at}
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
{/if}
</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}
<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>
{#if msg.created_at}
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
{/if}
</div>
{#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}
<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-success/50' : '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>
{/each}
<AgentIndicator
active={streaming || $activityLog.some((e) => e.status === 'running')}
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
{error}
/>
<div bind:this={messagesEnd}></div>
</div>
</div>
@@ -269,6 +343,7 @@
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
overflow-wrap: break-word;
}
/* Prose overrides */
@@ -337,12 +412,26 @@
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
.prose-chat :global(h1) {
font-size: 1.15em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h2) {
font-size: 1.08em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h3) {
font-size: 1.02em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
font-size: 1.03em;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
@@ -370,6 +459,13 @@
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(.table-wrapper) {
overflow-x: auto;
margin: 0 0 0.5rem;
}
.prose-chat :global(.table-wrapper table) {
margin: 0;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
@@ -433,4 +529,51 @@
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
/* Code copy button — global: injected via render() into {html} blocks */
.prose-chat :global(.code-block-wrapper) {
position: relative;
}
.prose-chat :global(.code-copy-btn) {
position: absolute;
top: 0.375rem;
right: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
color: var(--muted-foreground);
opacity: 0;
transition: opacity 0.15s, color 0.15s;
cursor: pointer;
border: none;
background: transparent;
}
.prose-chat :global(.code-block-wrapper:hover .code-copy-btn) {
opacity: 1;
}
.prose-chat :global(.code-copy-btn:hover) {
color: var(--foreground);
background: var(--muted);
}
/* Streaming cursor — blinking block appended after streaming text */
.stream-cursor {
display: inline-block;
width: 0.55em;
height: 1.1em;
background: var(--primary);
opacity: 0.75;
border-radius: 1px;
margin-left: 1px;
vertical-align: text-bottom;
animation: cursor-blink 0.9s ease-in-out infinite;
}
@keyframes cursor-blink {
0%, 100% { opacity: 0.75; }
50% { opacity: 0; }
}
</style>