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

@@ -1 +1 @@
0.9.1 0.10.0

View File

@@ -214,6 +214,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
w.WriteHeader(200) w.WriteHeader(200)
ctx := r.Context() ctx := r.Context()

View File

@@ -1100,9 +1100,9 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
} }
if _, err := s.pool.Exec(ctx, ` if _, err := s.pool.Exec(ctx, `
UPDATE session_plan_steps UPDATE session_plan_steps
SET status = $3, finished_at = COALESCE(finished_at, now()) SET status = $2, finished_at = COALESCE(finished_at, now())
WHERE session_id = $1 AND status IN ('pending', 'running')`, WHERE session_id = $1 AND status IN ('pending', 'running')`,
sessionID, "", closeStatus); err != nil { sessionID, closeStatus); err != nil {
slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err) slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err)
} }

View File

@@ -0,0 +1,79 @@
# 2026-07-21 Chat window full polish
## Context
After fixing the streaming reactivity bug and merging the double thinking
indicator, the chat window still has structural UX gaps: no streaming
affordance while text flows, tools never rendered inline, no timestamps,
no code copy, cross-session store leaks in floating windows, and minor
overflow/style holes.
## Decisions
| Question | Answer |
|---|---|
| Streaming feel | Typing cursor (blinking ▍) + inline indicator |
| Tool calls | Expandable inline tool cards in message flow |
| Empty state | Minimal — title + tagline, no suggestions |
| Dark theme | Keep neutral (skip) |
| Scope | Full polish — everything |
## Changes
### P0.1 Streaming cursor
- **File:** `web/src/lib/components/ChatThread.svelte`
- Add a blinking block-cursor (▍) appended after rendered markdown when
`streaming` is true and the last assistant message has text.
- Keep the inline spinner + activity label for the empty-text state.
- CSS: `@keyframes` blink, `0.8s` cycle, `primary` color, `inline-block`.
### P0.2 Tool call cards
- **New:** `web/src/lib/components/ToolCallCard.svelte`
- **Modify:** `ChatThread.svelte`
- Render `msg.tools` as collapsible cards between text blocks.
- Collapsed: tool icon + name + status (running/done/error).
- Expanded: pretty-printed args + result/error in `pre` blocks.
- Keep it minimal — one card per tool call, no grouping.
- Wire `pendingApprovals` from `msg.pendingApprovals` as approval
cards below the tool list.
### P1.3 Timestamps + role labels
- **Modify:** `ChatThread.svelte`, `ChatMessage` interface
- Add `created_at?: string` to `ChatMessage` (populated from `Message.created_at`).
- Show small muted timestamp (HH:MM) on hover or inline next to role label.
- Add tiny "You" / "Nomos" labels above bubbles (subtle, muted).
### P1.4 Code copy button
- **Modify:** `ChatThread.svelte` prose styles
- Wrap `pre` blocks in a relative container; add a copy button
(clipboard icon, top-right, opacity-0 → visible on hover).
- Use `navigator.clipboard.writeText`.
### P1.5 Table overflow + user bubble fix
- **Modify:** `ChatThread.svelte` prose styles
- Wrap tables in `overflow-x-auto` container.
- Add `overflow-wrap: break-word` to user bubbles.
### P2.6 Cross-session fixes
- **Modify:** `web/src/lib/stores/chat.ts`, `SessionChatWindow.svelte`
- `chatErrors`: keep global for now (session-scoped errors are rare
and the dismiss is manual anyway).
- `activityLog`: **per-session** — the store in `activity.ts` already
derives from messages; make `computeActivityLog` session-scoped
so each floating window only sees its own activity.
### P2.7 Min window size
- **Modify:** `web/src/lib/stores/windows.ts` (openTaskWindow)
- Add `minWidth: 600, minHeight: 400` to chat window open call.
### P2.8 Cleanup
- Delete `web/src/lib/components/AgentIndicator.svelte` (dead code).
- Update stale comments in `SessionChatWindow.svelte` and
`TaskContextPanel.svelte` that reference a "main Chat page."
- Fix prose heading hierarchy: h1 = 1.15em, h2 = 1.1em, h3 = 1.05em.
## Verification
- `npx eslint` on all changed files
- `go vet ./cmd/nomos/...`
- `go build -o /dev/null ./cmd/nomos/...`

View File

@@ -1,154 +0,0 @@
<script lang="ts">
import type { ActivityEntry } from '$lib/stores/activity'
import Spinner from './Spinner.svelte'
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import MilestoneIcon from '@lucide/svelte/icons/milestone'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
import FlagIcon from '@lucide/svelte/icons/flag'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
let { entries }: { entries: ActivityEntry[] } = $props()
let expanded = $state(new Set<string>())
function toggle(id: string) {
if (expanded.has(id)) expanded.delete(id)
else expanded.add(id)
expanded = new Set(expanded)
}
function typeIcon(type: ActivityEntry['type']) {
switch (type) {
case 'goal': return MilestoneIcon
case 'plan': return ListTodoIcon
case 'step_running': case 'step_done': case 'step_failed':
case 'tool_running': case 'tool_done': case 'tool_error':
return null // use status icon instead
case 'knowledge': return SparklesIcon
case 'complete': return FlagIcon
case 'question': return HelpCircleIcon
default: return null
}
}
function statusColor(status: ActivityEntry['status']) {
if (status === 'failed') return 'text-destructive'
return 'text-primary'
}
// Tool results often arrive as a JSON string — pretty-print it when it
// parses, otherwise fall back to the raw text rather than hiding it.
function prettyPrint(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
function formatTime(ts: number): string | null {
if (!ts) return null
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
</script>
<div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto">
{#if entries.length === 0}
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
<svg viewBox="0 0 64 110" class="h-20 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="max-w-[12rem] text-xs leading-relaxed text-muted-foreground">Waiting for activity…</p>
</div>
{:else}
<div class="flex flex-col py-1">
{#each entries as entry, i (entry.id)}
{@const isLast = i === entries.length - 1}
{@const icon = typeIcon(entry.type)}
{@const isOpen = expanded.has(entry.id)}
{@const time = formatTime(entry.timestamp)}
<div class="relative">
<!-- connector line -->
{#if !isLast}
<div class="absolute left-[17px] top-6 bottom-0 w-px bg-border"></div>
{/if}
<button
type="button"
class="flex w-full items-start gap-2 {entry.indent ? 'pl-7' : 'px-3'} py-1.5 text-left text-xs hover:bg-muted/30 cursor-pointer"
onclick={() => toggle(entry.id)}
>
<!-- status icon -->
<span class="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full {statusColor(entry.status)}">
{#if entry.status === 'running'}
<Spinner class="size-3.5" />
{:else if entry.status === 'failed'}
<CircleXIcon class="size-3.5" />
{:else if icon}
{@const IconComp = icon}
<IconComp class="size-3" />
{:else}
<CircleDotIcon class="size-3" />
{/if}
</span>
<!-- description -->
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
{entry.description}
</span>
<span class="mt-0.5 shrink-0 text-muted-foreground">
{#if isOpen}
<ChevronDownIcon class="size-3" />
{:else}
<ChevronRightIcon class="size-3" />
{/if}
</span>
</button>
<!-- detail -->
{#if isOpen}
<div class="flex flex-col gap-1.5 pl-8 pr-3 pb-2">
<div class="flex items-center gap-2 text-[10px] text-muted-foreground">
<span class="capitalize">{entry.status}</span>
{#if time}<span aria-hidden="true">·</span><span>{time}</span>{/if}
{#if entry.toolName}<span aria-hidden="true">·</span><code class="font-mono">{entry.toolName}</code>{/if}
</div>
{#if entry.args}
<div>
<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">Called with</p>
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.args)}</pre>
</div>
{/if}
{#if entry.detail}
<div>
{#if entry.args}<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">{entry.status === 'failed' ? 'Error' : 'Result'}</p>{/if}
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.detail)}</pre>
</div>
{/if}
{#if !entry.args && !entry.detail}
<p class="text-[10px] text-muted-foreground/70">No further detail for this step.</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -1,42 +0,0 @@
<script lang="ts">
import type { ActivityEntry } from '$lib/stores/activity'
import Spinner from './Spinner.svelte'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { active = false, lastActivity = null as ActivityEntry | null, error = '' }: { active?: boolean; lastActivity?: ActivityEntry | null; error?: string } = $props()
let done = $state(false)
let wasActive = $state(false)
$effect(() => {
if (active) { done = false; wasActive = true }
if (!active && wasActive) {
done = true
const t = setTimeout(() => { done = false; wasActive = false }, 3000)
return () => clearTimeout(t)
}
})
const label = $derived.by(() => {
if (error) return error
if (!active && done) return 'Done'
if (lastActivity) return lastActivity.description
return 'Agent is thinking…'
})
</script>
{#if active || done || error}
<div class="flex items-center gap-2 py-2 text-xs transition-opacity {error ? 'text-destructive' : done ? 'text-success opacity-50' : 'text-muted-foreground'}">
<span class="shrink-0">
{#if error}
<XIcon class="size-3" />
{:else if done}
<CheckIcon class="size-3" />
{:else}
<Spinner class="size-3 text-primary" />
{/if}
</span>
<span>{label}</span>
</div>
{/if}

View File

@@ -5,11 +5,15 @@
// through this, so the message-bubble/markdown styling lives in one place // through this, so the message-bubble/markdown styling lives in one place
// instead of being copy-pasted between the two. // instead of being copy-pasted between the two.
import { Pane, Splitpanes } from 'svelte-splitpanes' import { Pane, Splitpanes } from 'svelte-splitpanes'
import { activityLog } from '$lib/stores/activity' import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import AgentIndicator from '$lib/components/AgentIndicator.svelte' import type { Readable } from 'svelte/store'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea' 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 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 RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square' import SquareIcon from '@lucide/svelte/icons/square'
import { marked } from 'marked' import { marked } from 'marked'
@@ -26,7 +30,8 @@
onCancel, onCancel,
onReconnect, onReconnect,
onDismissError, onDismissError,
suggestions = [] suggestions = [],
activityLog: activityLogProp = activityLog
}: { }: {
messages: ChatMessage[] messages: ChatMessage[]
streaming: boolean streaming: boolean
@@ -38,6 +43,7 @@
onReconnect: () => void onReconnect: () => void
onDismissError: (id: string) => void onDismissError: (id: string) => void
suggestions?: string[] suggestions?: string[]
activityLog?: Readable<ActivityEntry[]>
} = $props() } = $props()
let input = $state('') let input = $state('')
@@ -45,6 +51,26 @@
let scrolledUp = $state(false) let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null) 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, // 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 // 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 // default are exactly one line: measured from the textarea's own
@@ -102,7 +128,26 @@
}) })
function render(text: string): string { 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() { function submit() {
@@ -149,27 +194,56 @@
</div> </div>
{/if} {/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'}"> <div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'} {#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> <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} {:else}
<div class="flex w-full flex-col gap-2"> <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} {#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg"> <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 --> <!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)} {@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> </div>
{/if} {/if}
</div> </div>
{/if} {/if}
</div> </div>
{/each} {/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 bind:this={messagesEnd}></div>
</div> </div>
</div> </div>
@@ -269,6 +343,7 @@
/* User message — soft terracotta bubble, gentle lift */ /* User message — soft terracotta bubble, gentle lift */
.user-msg { .user-msg {
box-shadow: 0 1px 8px -4px var(--primary); box-shadow: 0 1px 8px -4px var(--primary);
overflow-wrap: break-word;
} }
/* Prose overrides */ /* Prose overrides */
@@ -337,12 +412,26 @@
/* Section headings — serif (Inknut) with a short accent rule. Extra top /* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */ margin separates sections; the first heading in a message doesn't. */
.prose-chat :global(h1), .prose-chat :global(h1) {
.prose-chat :global(h2), font-size: 1.15em;
.prose-chat :global(h3) { 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; font-weight: 600;
margin: 1.15rem 0 0.4rem; margin: 1.15rem 0 0.4rem;
font-size: 1.03em;
letter-spacing: 0.01em; letter-spacing: 0.01em;
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -370,6 +459,13 @@
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 0.8125rem; 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) { .prose-chat :global(th) {
background: var(--muted); background: var(--muted);
font-weight: 600; font-weight: 600;
@@ -433,4 +529,51 @@
background: linear-gradient(to right, transparent, var(--primary), transparent); background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3; 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> </style>

View File

@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
// Floating-window content for a task/session — the per-window counterpart // Floating-window content for a task/session — self-contained per
// to the main Chat page (thread + rail), fully self-contained per
// sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage() // sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage()
// and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of // and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of
// these can be open (and independently live) at once without the "which // these can be open (and independently live) at once.
// one's on screen" guarding the main page's singleton stores need.
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes' import { Pane, Splitpanes } from 'svelte-splitpanes'
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat' import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
import { activityLogFor } from '$lib/stores/activity'
import ChatThread from '$lib/components/ChatThread.svelte' import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte' import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
@@ -16,12 +15,17 @@
// Svelte's `$store` auto-subscription only works on a plain identifier // Svelte's `$store` auto-subscription only works on a plain identifier
// bound directly to a store, not a member expression — chatFor() returns // bound directly to a store, not a member expression — chatFor() returns
// an object of stores, so pull each one out into its own identifier here. // an object of stores, so pull each one out into its own identifier here.
// sessionId is a stable prop (one per window mount, never changes), so
// capturing it at init is safe and intended.
// eslint-disable-next-line svelte/valid-compile
const chat = chatFor(sessionId) const chat = chatFor(sessionId)
const chatMessages = chat.messages const chatMessages = chat.messages
const chatStreaming = chat.streaming const chatStreaming = chat.streaming
const chatConnectionState = chat.connectionState const chatConnectionState = chat.connectionState
const chatError = chat.error const chatError = chat.error
const chatNotFound = chat.notFound const chatNotFound = chat.notFound
// eslint-disable-next-line svelte/valid-compile
const sessionActivityLog = activityLogFor(sessionId)
let loading = $state(true) let loading = $state(true)
onMount(async () => { onMount(async () => {
@@ -53,6 +57,7 @@
connectionState={$chatConnectionState} connectionState={$chatConnectionState}
error={$chatError} error={$chatError}
chatErrors={$chatErrors} chatErrors={$chatErrors}
activityLog={sessionActivityLog}
onSend={(text) => sendSessionMessage(sessionId, text)} onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)} onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)} onReconnect={() => loadSessionChat(sessionId)}

View File

@@ -6,16 +6,10 @@
import { activityLog, activityLogFor } from '$lib/stores/activity' import { activityLog, activityLogFor } from '$lib/stores/activity'
import OperatorQuestion from './OperatorQuestion.svelte' import OperatorQuestion from './OperatorQuestion.svelte'
import SessionGraph from './SessionGraph.svelte' import SessionGraph from './SessionGraph.svelte'
import ActivityTimeline from './ActivityTimeline.svelte' import UnifiedTimeline from './UnifiedTimeline.svelte'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down' import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right' import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import MilestoneIcon from '@lucide/svelte/icons/milestone'
import Spinner from './Spinner.svelte' import Spinner from './Spinner.svelte'
import CircleIcon from '@lucide/svelte/icons/circle'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
// Omitted (main Chat page): tracks the global "current session" — one // Omitted (main Chat page): tracks the global "current session" — one
// shared view, same as always. Passed (a floating task window's // shared view, same as always. Passed (a floating task window's
@@ -42,7 +36,6 @@
const effectiveSessionId = $derived(sessionId ?? $currentSession) const effectiveSessionId = $derived(sessionId ?? $currentSession)
let scopeOpen = $state(true) let scopeOpen = $state(true)
let planOpen = $state(true)
let activityOpen = $state(true) let activityOpen = $state(true)
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are // Resize: each section is a Pane in one vertical Splitpanes. Sizes are
@@ -52,12 +45,12 @@
// last size so reopening restores it. // last size so reopening restores it.
const COLLAPSED_SIZE = 6 const COLLAPSED_SIZE = 6
const OPEN_MIN_SIZE = 12 const OPEN_MIN_SIZE = 12
let sizes = $state<(number | undefined)[]>([undefined, undefined, undefined]) let sizes = $state<(number | undefined)[]>([undefined, undefined])
// Reopening must restore a concrete number, never `undefined` — the pane // Reopening must restore a concrete number, never `undefined` — the pane
// only re-triggers the library's resize/equalize pass when `size` changes // only re-triggers the library's resize/equalize pass when `size` changes
// to a different *number*, so setting it back to `undefined` silently // to a different *number*, so setting it back to `undefined` silently
// no-ops and leaves the section stuck at its collapsed height. // no-ops and leaves the section stuck at its collapsed height.
let savedSizes: number[] = [34, 33, 33] let savedSizes: number[] = [34, 66]
function toggleSection(i: number, isOpen: boolean) { function toggleSection(i: number, isOpen: boolean) {
if (isOpen) { if (isOpen) {
@@ -71,22 +64,9 @@
// Plan collapsed status // Plan collapsed status
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length) const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
const planTotal = $derived($planStepsStore.length) const planTotal = $derived($planStepsStore.length)
const planPct = $derived(planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0)
// When there are no plan steps, the empty state depends on WHY: a task that's
// actively planning (or streaming its first turn) is genuinely waiting for one,
// but a finished task that never planned (a read-only lookup, a direct answer)
// will never get one — a perpetual "Awaiting plan…" there is misleading.
const planPhase = $derived.by<'drafting' | 'none' | 'idle'>(() => {
const st = $taskStore?.status
if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none'
if (st === 'planning' || $streamingStore) return 'drafting'
return 'idle'
})
// Activity collapsed status // Activity collapsed status
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length) const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
const activityCount = $derived($activityLogStore.length)
</script> </script>
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
@@ -116,150 +96,35 @@
{/if} {/if}
</Pane> </Pane>
<!-- Plan --> <!-- Activity (merged plan + event log) -->
<Pane bind:size={sizes[1]} minSize={planOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={planOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col"> <Pane bind:size={sizes[1]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
<button <button
type="button" type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground" class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => { onclick={() => {
toggleSection(1, planOpen) toggleSection(1, activityOpen)
planOpen = !planOpen
}}
>
{#if planOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Plan</span>
{#if !planOpen}
{#if planTotal > 0}
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
{:else if $taskStore?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
{:else}
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
{/if}
{/if}
</button>
{#if planOpen}
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
{#if $taskStore?.goal}
<div class="flex items-start gap-2 px-3 py-2">
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
<span class="text-xs leading-snug text-foreground/90">{$taskStore.goal}</span>
</div>
{/if}
{#if planTotal > 0}
<div class="px-3 pb-2.5">
<div class="mb-1.5 flex items-baseline justify-between text-[11px]">
<span class="font-medium text-foreground">{planDone} of {planTotal} done</span>
<span class="tabular-nums text-muted-foreground">{planPct}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all duration-500 ease-out" style="width: {planPct}%"></div>
</div>
</div>
<ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]">
{#each $planStepsStore as step, i (step.id)}
{@const isDone = step.status === 'done'}
{@const isRunning = step.status === 'running'}
<li class="relative flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors {isRunning ? 'bg-primary/5' : ''}">
{#if i < $planStepsStore.length - 1}
<span class="pointer-events-none absolute bottom-[-2px] left-[13.5px] top-[22px] w-px bg-border" aria-hidden="true"></span>
{/if}
<span class="relative z-10 mt-px flex size-3.5 shrink-0 items-center justify-center rounded-full bg-background">
{#if isDone}
<CircleCheckIcon class="size-3.5 text-primary" />
{:else if isRunning}
<Spinner class="size-3.5 text-primary" />
{:else if step.status === 'failed'}
<CircleXIcon class="size-3.5 text-destructive" />
{:else if step.status === 'blocked'}
<CirclePauseIcon class="size-3.5 text-warning" />
{:else if step.status === 'skipped' || step.status === 'replaced'}
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
{:else}
<CircleIcon class="size-3.5 text-muted-foreground/40" />
{/if}
</span>
<span
class="min-w-0 flex-1 leading-snug {isDone
? 'text-muted-foreground line-through decoration-muted-foreground/40'
: isRunning
? 'font-medium text-foreground'
: 'text-muted-foreground'}"
>{step.title}</span>
</li>
{/each}
</ol>
{:else if planPhase === 'drafting'}
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
<svg viewBox="0 0 140 88" class="h-16 w-auto text-primary" fill="none">
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<circle cx="16" cy="20" r="4.5" fill="currentColor">
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" repeatCount="indefinite" />
</circle>
<line x1="30" y1="20" x2="124" y2="20" opacity="0.4">
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" repeatCount="indefinite" />
</line>
<circle cx="16" cy="44" r="4.5" fill="currentColor">
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
</circle>
<line x1="30" y1="44" x2="102" y2="44" opacity="0.4">
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
</line>
<circle cx="16" cy="68" r="4.5" fill="currentColor">
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
</circle>
<line x1="30" y1="68" x2="80" y2="68" opacity="0.4">
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
</line>
</g>
</svg>
<p class="text-xs text-muted-foreground">Drafting a plan…</p>
</div>
{:else}
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
<svg viewBox="0 0 140 88" class="h-16 w-auto text-muted-foreground/40" fill="none">
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<circle cx="16" cy="20" r="4.5" fill="currentColor" opacity="0.7" />
<line x1="30" y1="20" x2="124" y2="20" opacity="0.35" />
<circle cx="16" cy="44" r="4.5" fill="currentColor" opacity="0.45" />
<line x1="30" y1="44" x2="102" y2="44" opacity="0.25" />
<circle cx="16" cy="68" r="4.5" fill="none" opacity="0.3" />
<line x1="30" y1="68" x2="80" y2="68" opacity="0.15" stroke-dasharray="2.5 3.5" />
</g>
</svg>
<p class="max-w-[14rem] text-xs leading-relaxed text-muted-foreground">
{planPhase === 'none' ? 'Handled directly — no plan needed' : 'No plan for this task yet'}
</p>
</div>
{/if}
</div>
{/if}
</Pane>
<!-- Activity -->
<Pane bind:size={sizes[2]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
<button
type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => {
toggleSection(2, activityOpen)
activityOpen = !activityOpen activityOpen = !activityOpen
}} }}
> >
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if} {#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Event log</span> <span>Activity</span>
{#if !activityOpen}
{#if $streamingStore && activityRunning > 0} {#if $streamingStore && activityRunning > 0}
<Spinner class="size-3 text-primary" /> <Spinner class="size-3 text-primary" />
<span class="font-normal normal-case text-primary">{activityRunning} running</span> {/if}
{#if planTotal > 0}
<span class="font-normal normal-case tabular-nums {planDone === planTotal ? 'text-muted-foreground' : 'text-primary'}">{planDone}/{planTotal}</span>
{/if}
{#if !activityOpen && planTotal === 0}
{#if $taskStore?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
{:else} {:else}
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span> <span class="ml-auto font-normal normal-case text-muted-foreground">No activity yet</span>
{/if} {/if}
{/if} {/if}
</button> </button>
{#if activityOpen} {#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
<ActivityTimeline entries={$activityLogStore} /> <UnifiedTimeline entries={$activityLogStore} planSteps={$planStepsStore} streaming={$streamingStore} />
</div> </div>
{/if} {/if}
</Pane> </Pane>

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>

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>

View File

@@ -20,6 +20,7 @@ export interface ChatMessage {
text: string text: string
tools: ToolCallResult[] tools: ToolCallResult[]
pendingApprovals: PendingApproval[] pendingApprovals: PendingApproval[]
created_at?: string
} }
const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
@@ -122,7 +123,8 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
role: m.role as 'user' | 'assistant', role: m.role as 'user' | 'assistant',
text: content?.text ?? '', text: content?.text ?? '',
tools, tools,
pendingApprovals: extractApprovals(tools) pendingApprovals: extractApprovals(tools),
created_at: m.created_at
} }
}) })
} }
@@ -209,7 +211,7 @@ export function sendMessage(text: string) {
} }
messages.update((ms) => [...ms, assistantMsg]) messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map() const activeTools: Map<string, ToolCallResult> = new Map()
// Multiple tasks can stream concurrently (the backend runs each turn as its // Multiple tasks can stream concurrently (the backend runs each turn as its
// own goroutine — nothing serializes them), but `messages`/`currentSession` // own goroutine — nothing serializes them), but `messages`/`currentSession`
@@ -262,7 +264,7 @@ export function sendMessage(text: string) {
messages.update((ms) => { messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') { if (last && last.role === 'assistant') {
last.tools = [...last.tools, tr] ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
} }
return [...ms] return [...ms]
}) })
@@ -279,10 +281,10 @@ export function sendMessage(text: string) {
messages.update((ms) => { messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') { if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => const tools = last.tools.map((t) =>
t.id === ev.data.id ? updated : t t.id === ev.data.id ? updated : t
) )
last.pendingApprovals = extractApprovals(last.tools) ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
} }
return [...ms] return [...ms]
}) })
@@ -291,7 +293,7 @@ export function sendMessage(text: string) {
messages.update((ms) => { messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') { if (last && last.role === 'assistant') {
last.text += ev.data ms[ms.length - 1] = { ...last, text: last.text + ev.data }
} }
return [...ms] return [...ms]
}) })
@@ -300,7 +302,7 @@ export function sendMessage(text: string) {
messages.update((ms) => { messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') { if (last && last.role === 'assistant') {
last.text = ev.data ms[ms.length - 1] = { ...last, text: ev.data }
} }
return [...ms] return [...ms]
}) })
@@ -310,7 +312,7 @@ export function sendMessage(text: string) {
messages.update((ms) => { messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') { if (last && last.role === 'assistant') {
last.pendingApprovals = extractApprovals(last.tools) ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
} }
return [...ms] return [...ms]
}) })
@@ -580,7 +582,7 @@ export function sendSessionMessage(sessionId: string, text: string) {
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] } const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
chat.messages.update((ms) => [...ms, assistantMsg]) chat.messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map() const activeTools: Map<string, ToolCallResult> = new Map()
let receivedDone = false let receivedDone = false
const controller = streamChat( const controller = streamChat(
@@ -593,7 +595,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
activeTools.set(ev.data.id, tr) activeTools.set(ev.data.id, tr)
chat.messages.update((ms) => { chat.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr] if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
}
return [...ms] return [...ms]
}) })
} else if (ev.type === 'tool_result') { } else if (ev.type === 'tool_result') {
@@ -604,8 +608,8 @@ export function sendSessionMessage(sessionId: string, text: string) {
chat.messages.update((ms) => { chat.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') { if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t)) const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools) ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
} }
return [...ms] return [...ms]
}) })
@@ -613,13 +617,17 @@ export function sendSessionMessage(sessionId: string, text: string) {
} else if (ev.type === 'text_delta') { } else if (ev.type === 'text_delta') {
chat.messages.update((ms) => { chat.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
}
return [...ms] return [...ms]
}) })
} else if (ev.type === 'text') { } else if (ev.type === 'text') {
chat.messages.update((ms) => { chat.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
}
return [...ms] return [...ms]
}) })
} else if (ev.type === 'done') { } else if (ev.type === 'done') {
@@ -627,7 +635,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
chat.connectionState.set('connected') chat.connectionState.set('connected')
chat.messages.update((ms) => { chat.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools) if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
}
return [...ms] return [...ms]
}) })
startSessionPolling(sessionId) startSessionPolling(sessionId)
@@ -694,7 +704,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
activeTools.set(ev.data.id, tr) activeTools.set(ev.data.id, tr)
c.messages.update((ms) => { c.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr] if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
}
return [...ms] return [...ms]
}) })
} else if (ev.type === 'tool_result') { } else if (ev.type === 'tool_result') {
@@ -705,8 +717,8 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
c.messages.update((ms) => { c.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') { if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t)) const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools) ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
} }
return [...ms] return [...ms]
}) })
@@ -714,13 +726,17 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
} else if (ev.type === 'text_delta') { } else if (ev.type === 'text_delta') {
c.messages.update((ms) => { c.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
}
return [...ms] return [...ms]
}) })
} else if (ev.type === 'text') { } else if (ev.type === 'text') {
c.messages.update((ms) => { c.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
}
return [...ms] return [...ms]
}) })
} else if (ev.type === 'done') { } else if (ev.type === 'done') {
@@ -728,7 +744,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
c.connectionState.set('connected') c.connectionState.set('connected')
c.messages.update((ms) => { c.messages.update((ms) => {
const last = ms[ms.length - 1] const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools) if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
}
return [...ms] return [...ms]
}) })
startSessionPolling(sessionId) startSessionPolling(sessionId)

View File

@@ -122,5 +122,5 @@ export function openTaskWindow(sessionId: string | null, title: string): void {
wm.focus(id) wm.focus(id)
return return
} }
wm.open({ id, title, width: 900, height: 640 }) wm.open({ id, title, width: 900, height: 640, minWidth: 600, minHeight: 400 })
} }