v0.20.0: thinking blocks, chat windows overhaul, scroll fix
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

Backend:
- Add isThinking flag to agentEvent for text before tool calls
- Separate thinking from response text in runChatTurn and continue.go
- Persist thinking in a dedicated field in message content

Frontend:
- Add thinking field to MessageContent, ChatMessage, ChatTextEvent types
- Create ThinkingBlock.svelte — collapsible block with brain icon
- SSE handler moves text_delta content to thinking on isThinking flag
- Render thinking block between tools and response in ChatThread
- Fix chat window scroll reset on focus change (stable windowKeys order)
- Remove redundant #key id wrapper in WindowLayer
- Enlarge sidebar rail (24→32 default, 40→60 max)
- Remove glyph from sidebar, square graph at top
- Replace AgentTrace/ToolCallCard/UnifiedTimeline with TurnTrace/ToolLine
This commit is contained in:
2026-08-04 22:42:53 +02:00
parent 20adb89650
commit 1aaedf498a
25 changed files with 1852 additions and 1211 deletions

13
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "oikos-web",
"version": "0.1.0",
"dependencies": {
"@joan/procedural-glyph-engine": "file:../../../../../private/tmp/orby-pkg",
"@surdeddd/wmkit": "^0.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
@@ -43,6 +44,14 @@
"vitest": "^2.0.0"
}
},
"../../../../../private/tmp/orby-pkg": {
"name": "@joan/procedural-glyph-engine",
"version": "5.0.0",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=18"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
@@ -875,6 +884,10 @@
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@joan/procedural-glyph-engine": {
"resolved": "../../../../../private/tmp/orby-pkg",
"link": true
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",

View File

@@ -42,6 +42,7 @@
"vitest": "^2.0.0"
},
"dependencies": {
"@joan/procedural-glyph-engine": "file:../../../../../private/tmp/orby-pkg",
"@surdeddd/wmkit": "^0.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",

View File

@@ -1,128 +0,0 @@
<script lang="ts">
// The agent's working trace for one assistant turn: the live "thinking"
// indicator and that turn's tool calls merged into a single collapsible
// strip, instead of a stack of one card per call (a 13-call turn buried the
// actual answer). Collapsed it's one line — the current activity while
// running, a count once finished. Expanded it lists what the agent did, in
// humanized language, each row opening to its raw args/result.
import type { ToolCallResult } from '$lib/types'
import ToolCallCard from './ToolCallCard.svelte'
import Spinner from './Spinner.svelte'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
let {
tools = [],
label = null,
status = 'idle'
}: {
tools?: ToolCallResult[]
/** Live indicator text — the running step, an error, or "Done". */
label?: string | null
/** `idle` = no live state; the strip is just this turn's finished trace. */
status?: 'running' | 'done' | 'error' | 'idle'
} = $props()
let expanded = $state(false)
const count = $derived(tools.length)
// Collapsed line: prefer the live activity while something is happening,
// otherwise summarize the turn so a finished trace still says what it was.
const headline = $derived.by(() => {
if (status !== 'idle' && label) return label
if (count > 0) return count === 1 ? '1 tool call' : `${count} tool calls`
return 'No tool calls'
})
</script>
<div
class="trace rounded-lg border border-border/60 bg-card/40 transition-colors"
class:running={status === 'running'}
>
<button
class="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/40"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
aria-label={expanded ? 'Hide agent trace' : 'Show agent trace'}
>
<span
class="shrink-0 {status === 'error'
? 'text-destructive'
: status === 'idle'
? 'text-muted-foreground'
: 'text-primary'}"
>
{#if status === 'running'}
<Spinner class="size-3" />
{:else if status === 'error'}
<XIcon class="size-3" />
{:else if status === 'done'}
<CheckIcon class="size-3" />
{:else}
<SparklesIcon class="size-3" />
{/if}
</span>
<span
class="min-w-0 flex-1 truncate text-xs {status === 'error'
? 'text-destructive'
: status === 'running'
? 'text-foreground/80'
: 'text-muted-foreground'}"
>
{headline}
</span>
{#if count > 0 && status !== 'idle'}
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">{count}</span>
{/if}
<ChevronRightIcon
class="size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
? 'rotate-90'
: ''}"
/>
</button>
{#if expanded}
<div class="border-t border-border/40 p-1">
{#if count > 0}
{#each tools as tool (tool.id)}
<ToolCallCard {tool} />
{/each}
{:else}
<p class="px-2 py-1.5 text-[11px] text-muted-foreground">
Nothing recorded for this turn yet.
</p>
{/if}
</div>
{/if}
</div>
<style>
.trace {
animation: trace-in 0.2s ease-out;
}
/* A faint pulse while the agent is mid-turn — the collapsed strip is the
only thing on screen then, so it carries the "still working" signal. */
.trace.running {
border-color: color-mix(in oklab, var(--primary) 35%, var(--border));
}
@keyframes trace-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.trace {
animation: none;
}
}
</style>

View File

@@ -6,20 +6,26 @@
// instead of being copy-pasted between the two.
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import { resumeSession } from '$lib/api'
import type { Readable } from 'svelte/store'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import AgentTrace from './AgentTrace.svelte'
import TurnTrace from './TurnTrace.svelte'
import ThinkingBlock from './ThinkingBlock.svelte'
import OperatorQuestion from './OperatorQuestion.svelte'
import GlyphIndicator from './GlyphIndicator.svelte'
import Spinner from './Spinner.svelte'
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import CopyIcon from '@lucide/svelte/icons/copy'
import CheckIcon from '@lucide/svelte/icons/check'
import ArrowDownToLineIcon from '@lucide/svelte/icons/arrow-down-to-line'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
import type { ToolCallResult } from '$lib/types'
import type { SessionQuestion } from '$lib/api'
import type { PlanStep, SessionQuestion } from '$lib/api'
let {
messages,
@@ -36,7 +42,10 @@
activityLog: activityLogProp = activityLog,
sessionId = null,
question = null,
initialDraft = ''
initialDraft = '',
planSteps = [],
taskStatus,
lastActiveAt
}: {
messages: ChatMessage[]
streaming: boolean
@@ -64,6 +73,16 @@
* than making them retype it. Left editable on purpose — it is a starting
* point, not a command. */
initialDraft?: string
/** Current-generation plan steps for this session — rendered as a live
* checklist on the running turn (TodoWrite-style). Empty for a new/plan-less
* task and for the new-task launcher. */
planSteps?: PlanStep[]
/** Session status (active/planning/executing/…/done/failed). Drives the
* plan checklist's collapse-to-summary at a terminal state. */
taskStatus?: string
/** Session's last_active_at timestamp — used to detect a stuck turn
* (working but no activity for >5 min) and show elapsed time. */
lastActiveAt?: string
} = $props()
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
@@ -103,6 +122,63 @@
return 'Agent is thinking…'
})
// ── stuck detection + elapsed time ─────────────────────────────────────
// A turn is "stuck" when the server says working (planning/executing) but
// last_active_at is >5 min old — the agent's turn ended without updating
// the session status (crash, timeout, or a zombie gate). Show a distinct
// stuck indicator with a Resume button instead of a misleading "working…".
let resuming = $state(false)
let now = $state(Date.now())
$effect(() => {
if (!working) return
const id = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(id)
})
const elapsedSeconds = $derived(
working && lastActiveAt
? Math.max(0, Math.floor((now - new Date(lastActiveAt).getTime()) / 1000))
: 0
)
const isStuck = $derived(working && !streaming && elapsedSeconds > 300)
function formatElapsed(s: number): string {
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
}
async function handleResume() {
if (!sessionId || resuming) return
resuming = true
try {
await resumeSession(sessionId)
} finally {
resuming = false
}
}
// ── glyph backdrop ─────────────────────────────────────────────────────
// The agent's live semantic state, rendered as a faint procedural glyph
// behind the transcript. Computed from the same signals as the sidebar
// (status / working / streaming / connection / stuck) so the backdrop
// breathes with the agent without any store imports (prop-driven).
const agentSprite = $derived.by(() => {
if (connectionState !== 'connected') return 'status.offline'
if (taskStatus === 'failed') return 'status.error'
if (taskStatus === 'abandoned') return 'status.cancelled'
if (taskStatus === 'done') return 'status.success'
if (taskStatus === 'awaiting_input') return 'ai.listening'
if (isStuck) return 'status.warning'
if (streaming) return 'ai.speaking'
if (working) return 'ai.still-working'
if (taskStatus === 'planning') return 'ai.thinking'
return 'ai.idle'
})
// 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
@@ -160,14 +236,21 @@
// unless user scrolled up to read. Sets scrollTop on the messages container
// directly instead of `scrollIntoView`, which walks ancestors and forces a
// reflow that can momentarily perturb the window titlebar height.
//
// During streaming, scroll INSTANTLY (behavior: 'auto') — the content is
// growing continuously, so a smooth animation constantly chases a moving
// target and produces the jerky "jumping" the operator sees. For
// non-streaming updates (a completed message, a question), a smooth scroll
// is fine. Uses requestAnimationFrame so the scroll lands after the DOM
// update, not 50ms later.
$effect(() => {
void messages
void question
if (streaming || !scrolledUp) {
setTimeout(
() => container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }),
50
)
const behavior = streaming ? ('auto' as const) : ('smooth' as const)
requestAnimationFrame(() => {
container?.scrollTo({ top: container.scrollHeight, behavior })
})
}
})
@@ -216,25 +299,51 @@
onSend(q)
}
// Merge live streaming output (from the activity log's `run` entry) onto the
// in-flight turn's tool calls so the inline tool card shows command output as
// it arrives — the place the operator naturally "checks the tool". Only the
// last assistant message can be streaming, so only it gets enriched; history
// is untouched (and has no live output anyway). (F4)
function toolsWithLive(
tools: ToolCallResult[],
entries: ActivityEntry[],
isLiveTurn: boolean
): ToolCallResult[] {
if (!isLiveTurn) return tools
const liveById = new Map<string, string>()
for (const e of entries) {
if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
// Per-message copy affordance (border-driven icon button on each row).
let copiedId = $state<string | null>(null)
async function copyMessage(msg: ChatMessage) {
try {
await navigator.clipboard.writeText(msg.text)
copiedId = msg.id
setTimeout(() => {
if (copiedId === msg.id) copiedId = null
}, 1400)
} catch {
/* clipboard unavailable — silently no-op */
}
if (liveById.size === 0) return tools
return tools.map((t) =>
t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
)
}
// Scroll-to-bottom: uses container.scrollTo (never scrollIntoView, which
// reflows ancestor wmkit panes — see wmkit.scrollintoview_reflow_pitfall).
function jumpToBottom() {
container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
scrolledUp = false
}
// Enrich a turn's tool calls with live `run` output AND plan-step
// attribution pulled from the activity log (keyed by tool id), so the inline
// TurnTrace can pin streaming output to its tool and group calls under their
// step. Run for every turn (not just the live one) so historical turns group
// correctly too; unmapped tools pass through unchanged.
function enrichTools(tools: ToolCallResult[], entries: ActivityEntry[]): ToolCallResult[] {
const byId = new Map<string, { liveOutput?: string; stepSeq?: number }>()
for (const e of entries) {
if (!e.id) continue
const cur = byId.get(e.id) ?? {}
if (e.liveOutput) cur.liveOutput = e.liveOutput
if (e.stepSeq != null) cur.stepSeq = e.stepSeq
byId.set(e.id, cur)
}
if (byId.size === 0) return tools
return tools.map((t) => {
if (!t.id) return t
const e = byId.get(t.id)
if (!e) return t
const next: ToolCallResult = { ...t }
if (e.liveOutput) next.liveOutput = e.liveOutput
if (e.stepSeq != null) next.stepSeq = e.stepSeq
return next
})
}
</script>
@@ -247,107 +356,117 @@
on:resize={() => (userResizedInput = true)}
>
<Pane class="flex flex-col">
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
{#if messages.length === 0}
<div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">
Your resident operator. Ask about the fleet, or tell it to act.
</p>
</div>
{#if suggestions.length}
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button
variant="outline"
size="sm"
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
onclick={() => ask(q)}
>
{q}
</Button>
{/each}
</div>
{/if}
</div>
{/if}
{#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>
{#if idx === messages.length - 1 && working && !streaming}
<!-- The last message is this user bubble and the agent is
working but not live-streaming → the message was queued
behind an in-flight turn (plan 2026-08-03 F2). It'll run
when the current step finishes. -->
<span class="px-1 text-[10px] text-muted-foreground"
>Queued — Nomos will run this when it finishes the current step.</span
>
{/if}
{:else}
{@const isLast = idx === messages.length - 1}
{@const traceStatus = !isLast
? 'idle'
: error
? 'error'
: working
? 'running'
: indicatorDone
? 'done'
: 'idle'}
<div class="flex w-full flex-col gap-2">
<div class="flex items-baseline gap-2 px-1">
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
{#if msg.created_at}
<span class="text-[9px] text-muted-foreground/50"
>{formatTime(msg.created_at)}</span
>
{/if}
</div>
<!-- The working trace sits above the answer: it's what happened
first, and collapsed it keeps a long tool run from burying
the text below it. -->
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
<AgentTrace
tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
status={traceStatus}
label={traceStatus === 'idle' ? null : indicatorLabel}
/>
{/if}
{#if msg.text}
<div
class="markdown-body 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 isLast && streaming}
<span class="stream-cursor" aria-hidden="true"></span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/each}
{#if question}
<OperatorQuestion {sessionId} {question} />
{/if}
<div class="relative min-h-0 flex-1">
<!-- Glyph backdrop — the agent's live semantic state as a faint
procedural watermark behind the transcript. Fixed (doesn't scroll
with the messages), pointer-events none, behind the content. -->
<div class="glyph-backdrop" aria-hidden="true">
<GlyphIndicator sprite={agentSprite} seed={sessionId ?? 'oikos'} size={640} opacity={0.07} />
</div>
<div class="relative z-[1] h-full overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex min-h-full max-w-3xl flex-col divide-y divide-border px-4">
{#if messages.length === 0}
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">
Your resident operator. Ask about the fleet, or tell it to act.
</p>
</div>
{#if suggestions.length}
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button
variant="outline"
size="sm"
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
onclick={() => ask(q)}
>
{q}
</Button>
{/each}
</div>
{/if}
</div>
{/if}
{#each messages as msg, idx (msg.id)}
{@const isLast = idx === messages.length - 1}
<div class="msg-row relative flex gap-3 py-3">
<div class="msg-role" aria-hidden="true">{msg.role === 'user' ? 'YOU' : 'NOMOS'}</div>
<div class="msg-body min-w-0 flex-1">
{#if msg.role === 'user'}
<div class="user-text whitespace-pre-wrap text-sm leading-relaxed">{msg.text}</div>
{#if msg.created_at}
<div class="msg-time">{formatTime(msg.created_at)}</div>
{/if}
{#if isLast && working && !streaming}
<!-- The last message is this user row and the agent is working but not
live-streaming → the message was queued behind an in-flight turn
(plan 2026-08-03 F2). It'll run when the current step finishes. -->
<div class="queued-hint">Queued — runs when Nomos finishes the current step.</div>
{/if}
{:else}
{@const traceStatus = !isLast
? 'idle'
: error
? 'error'
: working
? 'running'
: indicatorDone
? 'done'
: 'idle'}
<!-- Inline progressive trace: live plan checklist (last turn) +
thinking line + per-tool lines, then the streamed answer. -->
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
<TurnTrace
tools={enrichTools(msg.tools, $activityLogProp)}
status={traceStatus}
label={traceStatus === 'idle' ? null : indicatorLabel}
{isLast}
planSteps={isLast ? planSteps : []}
{taskStatus}
/>
{/if}
{#if msg.thinking}
<ThinkingBlock thinking={msg.thinking} />
{/if}
{#if msg.text}
<div
class="markdown-body prose-chat max-w-none text-sm leading-relaxed"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
{#if isLast && streaming}
<span class="stream-cursor" aria-hidden="true"></span>
{/if}
</div>
{#if msg.created_at}
<div class="msg-time">{formatTime(msg.created_at)}</div>
{/if}
{/if}
{/if}
</div>
<button
class="msg-action"
title="Copy message"
aria-label="Copy message"
onclick={() => copyMessage(msg)}
>
{#if copiedId === msg.id}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
</button>
</div>
{/each}
{#if question}
<div class="py-3"><OperatorQuestion {sessionId} {question} /></div>
{/if}
</div>
</div>
{#if scrolledUp && messages.length > 0}
<button class="jump-bottom" onclick={jumpToBottom} aria-label="Jump to latest">
<ArrowDownToLineIcon class="size-4" />
</button>
{/if}
</div>
{#if connectionState === 'disconnected'}
@@ -420,20 +539,36 @@
spinner + fg label + primary-tinted hairline border, aligned to the
textarea column. -->
<div class="mx-auto w-full max-w-3xl px-4">
<div class="composer-status mb-2">
<Spinner class="size-3 shrink-0 text-primary" />
<span class="composer-status-label">Working</span>
<span class="composer-status-text"
>message will queue — runs when Nomos is free</span
>
</div>
{#if isStuck}
<div class="composer-status composer-status-stuck mb-2">
<span class="composer-status-label stuck-label">Stuck</span>
<span class="composer-status-text"
>no activity for {formatElapsed(elapsedSeconds)}</span
>
<Button
size="xs"
variant="outline"
class="h-6 text-[11px]"
onclick={handleResume}
disabled={resuming}
>
{resuming ? 'Resuming…' : 'Resume'}
</Button>
</div>
{:else}
<div class="composer-status mb-2">
<Spinner class="size-3 shrink-0 text-primary" />
<span class="composer-status-label">Working</span>
<span class="composer-status-text">{formatElapsed(elapsedSeconds)}</span>
</div>
{/if}
</div>
{/if}
</Pane>
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
<div
class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative"
class="flex h-full min-h-0 flex-col border-t bg-background p-3 input-ornament relative"
bind:this={inputWrapperRef}
>
<form
@@ -448,7 +583,7 @@
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
class="h-full max-h-none min-h-0 resize-none px-4 py-3 pr-12 field-sizing-fixed"
disabled={streaming}
/>
{#if streaming}
@@ -456,7 +591,7 @@
type="button"
size="icon-sm"
variant="secondary"
class="absolute right-2 bottom-2 rounded-lg"
class="absolute right-2 bottom-2"
onclick={onCancel}
aria-label="Stop"
>
@@ -467,7 +602,7 @@
type="submit"
size="icon-sm"
variant="secondary"
class="absolute right-2 bottom-2 rounded-lg"
class="absolute right-2 bottom-2"
disabled={!input.trim()}
aria-label="Send"
>
@@ -481,34 +616,110 @@
</div>
<style>
/* ── Art Nouveau chat styling ── */
/* ── Cyberspace / terminal chat styling ──
Messages are full-width terminal log rows (left role-tag column +
content), separated by hairline divide-y. Border-driven, square, no soft
shadows — same language as the rest of the app. Prose deltas below sit on
top of the shared .markdown-body base (app.css); a two-class selector
(`.markdown-body.prose-chat`) wins on specificity over app.css's single
`.markdown-body` rules deterministically, regardless of <style> injection
order. */
/* Assistant message wrapper */
.assistant-msg {
position: relative;
/* Message rows */
.msg-row {
/* role column + body; the copy action is absolutely positioned top-right */
}
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
.msg-role {
flex-shrink: 0;
width: 3.25rem;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted-foreground);
padding-top: 0.15rem;
}
.msg-body {
display: flex;
flex-direction: column;
gap: 0.3rem;
overflow-wrap: break-word;
}
.user-text {
color: var(--foreground);
}
.msg-time {
font-family: var(--font-mono);
font-size: 9px;
color: var(--muted-foreground);
opacity: 0.7;
}
.queued-hint {
font-size: 10px;
color: var(--muted-foreground);
}
.msg-action {
position: absolute;
top: 0.6rem;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: 1px solid transparent;
background: transparent;
color: var(--muted-foreground);
opacity: 0;
transition:
opacity 0.12s,
color 0.12s,
border-color 0.12s;
}
.msg-row:hover .msg-action,
.msg-action:focus-visible {
opacity: 1;
}
.msg-action:hover {
color: var(--foreground);
border-color: var(--border);
}
/* Prose overrides — deltas on top of the shared .markdown-body base
(app.css) only. The template applies both classes together
(class="markdown-body prose-chat ..."); everything below either adds a
look .markdown-body doesn't have (li::marker, the pre/blockquote
::before ornaments, hr, strong, the table-wrapper, the code-copy
button) or overrides a .markdown-body value that this "Art Nouveau"
chat treatment wants different (code/pre padding, heading size, th/td
padding, blockquote border color, link underline style). Anywhere a
value is actually overridden, the selector is
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
:global() selectors from two different <style> blocks land in the same
stylesheet with no scoping to arbitrate between them, so equal
specificity would leave the winner to injection order (unreliable
across dev/build). The two-class selector's higher specificity wins
deterministically regardless. */
/* Glyph backdrop — fills the message pane, centers the glyph, stays put
while the transcript scrolls over it. pointer-events none so it never
intercepts scroll/click. */
.glyph-backdrop {
position: absolute;
inset: 0;
z-index: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
overflow: hidden;
}
/* Jump-to-latest button — border-driven square, sits over the transcript */
.jump-bottom {
position: absolute;
right: 1rem;
bottom: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--border);
background: var(--background);
color: var(--muted-foreground);
box-shadow: 2px 2px 0 0 var(--border);
}
.jump-bottom:hover {
color: var(--foreground);
border-color: var(--foreground);
}
/* Prose deltas (markdown-body base lives in app.css). */
.prose-chat :global(li) {
padding-left: 0.25rem;
}
@@ -541,8 +752,8 @@
border: none;
}
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
/* Headings — short accent rule under each; first heading in a message
doesn't get extra top margin. */
.markdown-body.prose-chat :global(h1) {
font-size: 1.15em;
margin: 1.15rem 0 0.4rem;
@@ -577,7 +788,6 @@
width: 2.5rem;
height: 2px;
margin-top: 4px;
border-radius: 1px;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.55;
}
@@ -604,7 +814,7 @@
position: relative;
}
.prose-chat :global(blockquote)::before {
content: '';
content: '"';
position: absolute;
left: -0.15rem;
top: -0.35rem;
@@ -628,8 +838,6 @@
);
}
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
terracotta accent stay meaningful (code, headings, links). */
.prose-chat :global(strong) {
color: var(--foreground);
font-weight: 600;
@@ -641,7 +849,7 @@
text-underline-offset: 2px;
}
/* Input area ornament */
/* Input area hairline ornament */
.input-ornament::before {
content: '';
position: absolute;
@@ -654,9 +862,8 @@
}
/* Composer "working/queued" status strip — terminal status-bar idiom: a
hairline primary-tinted border (matching .trace.running), square corners,
a spinner + an uppercase fg label + muted detail. Border-driven, no
shadow — same language as the rest of the cyberspace surfaces. */
hairline primary-tinted border, square corners, a spinner + an uppercase
fg label + muted detail. Border-driven, no shadow. */
.composer-status {
display: flex;
align-items: center;
@@ -676,8 +883,16 @@
.composer-status-text {
color: var(--muted-foreground);
}
.composer-status-stuck {
border-color: color-mix(in oklab, var(--warning) 50%, var(--border));
background: color-mix(in oklab, var(--warning) 8%, var(--card));
}
.stuck-label {
color: var(--warning);
}
/* Code copy button — global: injected via render() into {html} blocks */
/* Code copy button — global: injected via render() into {html} blocks.
Square (cyberspace), not rounded. */
.prose-chat :global(.code-block-wrapper) {
position: relative;
}
@@ -690,7 +905,6 @@
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
color: var(--muted-foreground);
opacity: 0;
transition:
@@ -715,7 +929,6 @@
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;
@@ -730,4 +943,12 @@
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stream-cursor,
.msg-action {
animation: none;
transition: none;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import {
createGlyph,
type JoanGlyphEngine,
type SpriteName
} from '@joan/procedural-glyph-engine'
import { getTheme } from '$lib/stores/theme.svelte'
let {
sprite,
seed = 'oikos',
size = 96,
opacity = 1
}: {
sprite: string
seed?: string
/** Display max-width in px (the engine's internal grid stays 96; CSS
* upscales pixelated for larger backdrops). */
size?: number
/** Canvas opacity — <1 for a faint watermark backdrop. */
opacity?: number
} = $props()
let canvas = $state<HTMLCanvasElement | null>(null)
let glyph = $state<JoanGlyphEngine | null>(null)
function palette(t: 'light' | 'dark') {
return {
background: 'transparent',
off: t === 'dark' ? '#1a1a1a' : '#e6dcc0',
ink: t === 'dark' ? '#efe5c0' : '#000000',
accent: t === 'dark' ? '#a89984' : '#3a3a3a',
glow: 'transparent'
}
}
onMount(() => {
const g = createGlyph(canvas!, {
sprite: sprite as SpriteName,
seed,
gridSize: 96,
palette: palette(getTheme()),
background: false,
orbBackgroundColor: 'transparent',
orbBackgroundMode: 'none'
})
glyph = g
const obs = new MutationObserver(() => {
g.configure({ palette: palette(getTheme()) })
})
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => obs.disconnect()
})
onDestroy(() => {
glyph?.destroy()
})
$effect(() => {
if (glyph && sprite) {
glyph.transitionTo(sprite as SpriteName)
}
})
</script>
<canvas
bind:this={canvas}
width="96"
height="96"
class="glyph"
style="max-width:{size}px;opacity:{opacity}"
aria-hidden="true"
></canvas>
<style>
.glyph {
display: block;
width: 100%;
aspect-ratio: 1;
margin: 0 auto;
image-rendering: pixelated;
}
</style>

View File

@@ -15,7 +15,7 @@
chatErrors
} from '$lib/stores/chat'
import { activityLogFor } from '$lib/stores/activity'
import { workspaceFor, startSessionWorkspace, taskWorking } from '$lib/stores/workspace'
import { workspaceFor, startSessionWorkspace, taskWorking, taskFor } from '$lib/stores/workspace'
import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
@@ -42,6 +42,9 @@
// context rail mounts.
// eslint-disable-next-line svelte/valid-compile
const workspace = workspaceFor(sessionId)
const planSteps = workspace.planSteps
// eslint-disable-next-line svelte/valid-compile
const chatTask = taskFor(sessionId)
const openQuestion = workspace.openQuestion
let loading = $state(true)
@@ -73,7 +76,7 @@
// Resizable right rail — sized smaller by default since task windows open
// narrower than the full page.
let railSize = $state(24)
let railSize = $state(32)
</script>
<div class="flex h-full min-h-0">
@@ -99,13 +102,16 @@
activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
planSteps={$planSteps}
taskStatus={$chatTask?.status}
lastActiveAt={$chatTask?.last_active_at}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
</Pane>
<Pane bind:size={railSize} minSize={18} maxSize={40}>
<Pane bind:size={railSize} minSize={24} maxSize={60}>
<TaskContextPanel {sessionId} />
</Pane>
</Splitpanes>

View File

@@ -75,6 +75,18 @@
let cw = $state(300)
let ch = $state(300)
// View transform (zoom-to-fit + drag-pan). The force simulation runs in its
// own graph coordinate space; this maps graph→screen so every entity stays
// visible regardless of how far the layout spreads or how narrow the panel
// is. tx/ty are screen px; scale is unitless. `userPanned` pauses auto-fit
// once the operator drags the background, until the entity set changes or
// they double-click to reset.
let tx = $state(0)
let ty = $state(0)
let scale = $state(1)
let userPanned = $state(false)
const viewTransform = $derived(`translate(${tx},${ty}) scale(${scale})`)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
@@ -206,6 +218,7 @@
.alphaDecay(0.045)
.on('tick', () => {
nodes = [...nodes]
if (!userPanned) fitView()
})
}
@@ -261,6 +274,49 @@
return slug.split(':').pop() ?? slug
}
// Compute the view transform that fits every node (with label clearance)
// inside the panel, clamped so a single node doesn't fill it and a huge
// graph stays legible. No-op until the layout has positions / a size.
function fitView() {
if (!nodes.length || cw <= 1 || ch <= 1) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
if (n.x == null || n.y == null) continue
const r = nodeRadius(n) + 12 // node + label clearance
minX = Math.min(minX, n.x - r)
minY = Math.min(minY, n.y - r)
maxX = Math.max(maxX, n.x + r)
maxY = Math.max(maxY, n.y + r)
}
if (!Number.isFinite(minX)) return
const pad = 16
const w = Math.max(maxX - minX, 1)
const h = Math.max(maxY - minY, 1)
const s = Math.min((cw - pad * 2) / w, (ch - pad * 2) / h)
const clamped = Math.max(0.2, Math.min(2.5, Number.isFinite(s) ? s : 1))
scale = clamped
tx = (cw - w * clamped) / 2 - minX * clamped
ty = (ch - h * clamped) / 2 - minY * clamped
}
// When the entity SET changes (a new node added/removed), re-engage auto-fit
// so the new entity is brought into view. Same-slug re-renders (every sim
// tick) leave the signature unchanged and don't reset.
let lastMembership = ''
$effect(() => {
const sig = nodes
.map((n) => n.slug)
.sort()
.join('|')
if (sig !== lastMembership) {
lastMembership = sig
userPanned = false
}
})
// Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
// object identity fine and this is small (≤12 touched, ≤8 diffs).
@@ -283,16 +339,22 @@
return typeof end === 'object' ? end.slug : end
}
// ─── drag / select ───────────────────────────────────────────────────
// ─── drag / select / pan ─────────────────────────────────────────────
// A click (pointerdown+up with no movement in between) opens the entity
// straight in its own floating window (WindowLayer) instead of a
// click-through mini-panel — `selected` now only drives the highlight/dim
// styling below, so you can see at a glance which node you last opened.
// straight in its own floating window (WindowLayer); `selected` only drives
// the highlight/dim styling. Node drag pins the node in GRAPH coords
// (screen→graph via the inverse view transform). Background drag pans the
// view and sets userPanned so auto-fit pauses. Double-click background
// re-fits all entities.
let dragState: { node: Node; moved: boolean } | null = null
let panState: { x: number; y: number } | null = null
function toLocal(clientX: number, clientY: number) {
function toGraph(clientX: number, clientY: number) {
const rect = container!.getBoundingClientRect()
return { x: clientX - rect.left, y: clientY - rect.top }
return {
x: (clientX - rect.left - tx) / scale,
y: (clientY - rect.top - ty) / scale
}
}
function onNodeDown(e: PointerEvent, node: Node) {
@@ -301,26 +363,44 @@
dragState = { node, moved: false }
sim?.alphaTarget(0.2).restart()
}
function onBgDown(e: PointerEvent) {
panState = { x: e.clientX - tx, y: e.clientY - ty }
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
}
function onMove(e: PointerEvent) {
if (!dragState) return
const p = toLocal(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
if (dragState) {
const p = toGraph(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
return
}
if (panState) {
tx = e.clientX - panState.x
ty = e.clientY - panState.y
userPanned = true
}
}
function selectAndOpen(node: Node) {
selected = node
openEntityWindow(node.slug)
}
function onUp() {
if (!dragState) return
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectAndOpen(node)
if (dragState) {
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectAndOpen(node)
return
}
panState = null
}
function refit() {
userPanned = false
fitView()
}
const selectedRelations = $derived(
@@ -342,7 +422,7 @@
)
</script>
<aside class="flex h-full min-h-0 flex-col bg-card/40">
<aside class="flex h-full min-h-0 flex-col bg-card">
{#if nowTouching}
<div
class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
@@ -447,9 +527,11 @@
class="h-full w-full touch-none select-none"
role="application"
aria-label="Session entity graph"
onpointerdown={onBgDown}
onpointermove={onMove}
onpointerup={onUp}
onpointercancel={onUp}
ondblclick={refit}
>
<defs>
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
@@ -457,8 +539,9 @@
</pattern>
</defs>
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
<g>
{#each links as link}
<g transform={viewTransform}>
<g>
{#each links as link}
{@const s = endpoint(link.source)}
{@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
@@ -560,6 +643,7 @@
{/if}
{/each}
</g>
</g>
</svg>
{/if}
</div>

View File

@@ -1,172 +1,65 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import {
startWorkspace,
planSteps,
currentTask,
currentWorking,
touched,
healthDiffs,
workspaceFor,
taskFor,
taskWorking
taskWorking,
currentWorking,
currentTask
} from '$lib/stores/workspace'
import { messages, chatFor } from '$lib/stores/chat'
import { activityLog, activityLogFor } from '$lib/stores/activity'
import { messages, chatFor, streaming, connectionState } from '$lib/stores/chat'
import SessionGraph from './SessionGraph.svelte'
import UnifiedTimeline from './UnifiedTimeline.svelte'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import Spinner from './Spinner.svelte'
// Omitted (main Chat page): tracks the global "current session" — one
// shared view, same as always. Passed (a floating task window's
// SessionChatWindow): this panel switches entirely to that session's own
// store bundle (workspaceFor/chatFor/activityLogFor), so several windows'
// panels can be open and live at once instead of all showing whatever
// happens to be the single global "current session". Per-session workspace
// tracking (startSessionWorkspace) is started by SessionChatWindow itself,
// not here — it has to run even while this panel stays unmounted (see its
// hasContext gate), so only the global fallback path starts its own here.
let { sessionId = null }: { sessionId?: string | null } = $props()
onMount(() => (sessionId ? undefined : startWorkspace()))
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
const touchedStore = $derived(ws ? ws.touched : touched)
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
const chat = $derived(sessionId ? chatFor(sessionId) : null)
const workingStore = $derived(sessionId ? taskWorking(sessionId) : currentWorking)
const messagesStore = $derived(chat ? chat.messages : messages)
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
let scopeOpen = $state(true)
let activityOpen = $state(true)
// eslint-disable-next-line svelte/valid-compile
const taskWorkingStore = sessionId ? taskWorking(sessionId) : currentWorking
// eslint-disable-next-line svelte/valid-compile
const taskStreamingStore = sessionId ? chatFor(sessionId).streaming : streaming
// eslint-disable-next-line svelte/valid-compile
const taskConnStore = sessionId ? chatFor(sessionId).connectionState : connectionState
// eslint-disable-next-line svelte/valid-compile
const taskStatusStore = sessionId ? taskFor(sessionId) : currentTask
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
// percentages of the panel's height; undefined means "share the space
// evenly with the other auto sections". Collapsing a section pins it to
// COLLAPSED_SIZE (roughly a header's worth of height) and remembers its
// last size so reopening restores it.
const COLLAPSED_SIZE = 6
const OPEN_MIN_SIZE = 12
let sizes = $state<(number | undefined)[]>([30, 70])
// Reopening must restore a concrete number, never `undefined` — the pane
// only re-triggers the library's resize/equalize pass when `size` changes
// to a different *number*, so setting it back to `undefined` silently
// no-ops and leaves the section stuck at its collapsed height.
let savedSizes: number[] = [30, 70]
// ── stuck detection (mirrors ChatThread) ───────────────────────────────
let now = $state(Date.now())
$effect(() => {
if (!$taskWorkingStore) return
const id = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(id)
})
const lastActiveAt = $derived($taskStatusStore?.last_active_at)
const isStuck = $derived(
$taskWorkingStore &&
!$taskStreamingStore &&
lastActiveAt &&
now - new Date(lastActiveAt).getTime() > 300_000
)
function toggleSection(i: number, isOpen: boolean) {
if (isOpen) {
savedSizes[i] = sizes[i] ?? savedSizes[i]
sizes[i] = COLLAPSED_SIZE
} else {
sizes[i] = savedSizes[i]
}
}
// Plan collapsed status
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
const planTotal = $derived($planStepsStore.length)
// Activity collapsed status
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
</script>
<div class="flex h-full min-h-0 flex-col">
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
<!-- Scope -->
<Pane
bind:size={sizes[0]}
minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
maxSize={scopeOpen ? 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(0, scopeOpen)
scopeOpen = !scopeOpen
}}
>
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
class="size-3"
/>{/if}
<span>Scope</span>
{#if !scopeOpen}
<span class="ml-auto font-normal normal-case"
>{$touchedStore.length
? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}`
: 'Graph'}</span
>
{/if}
</button>
{#if scopeOpen}
<div class="min-h-0 flex-1">
<SessionGraph
messages={$messagesStore}
touched={$touchedStore}
healthDiffs={$healthDiffsStore}
/>
</div>
{/if}
</Pane>
<!-- Activity (merged plan + event log) -->
<Pane
bind:size={sizes[1]}
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(1, activityOpen)
activityOpen = !activityOpen
}}
>
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
class="size-3"
/>{/if}
<span>Activity</span>
{#if $workingStore && activityRunning > 0}
<Spinner class="size-3 text-primary" />
{/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}
<span class="ml-auto font-normal normal-case text-muted-foreground"
>No activity yet</span
>
{/if}
{/if}
</button>
{#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden">
<UnifiedTimeline
entries={$activityLogStore}
planSteps={$planStepsStore}
streaming={$workingStore}
/>
</div>
{/if}
</Pane>
</Splitpanes>
<div class="shrink-0" style="aspect-ratio: 1; width: 100%;">
<SessionGraph
messages={$messagesStore}
touched={$touchedStore}
healthDiffs={$healthDiffsStore}
/>
</div>
</div>
<style>
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { Brain, ChevronRight } from '@lucide/svelte'
let { thinking }: { thinking: string } = $props()
let expanded = $state(false)
</script>
<div class="thinking-block">
<button
class="row"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
>
<Brain class="icon size-3" />
<span class="text min-w-0 flex-1">
<span class="label">Thought{thinking.includes('\n') ? 's' : ''}</span>
</span>
<span class="summary">{thinking.slice(0, 60).replace(/\n/g, ' ')}{thinking.length > 60 ? '…' : ''}</span>
<ChevronRight class="chev size-3 {expanded ? 'open' : ''}" />
</button>
{#if expanded}
<div class="detail"><pre>{thinking}</pre></div>
{/if}
</div>
<style>
.thinking-block {
border-left: 1px solid var(--border);
padding-left: 0.5rem;
animation: thinking-in 0.15s ease-out;
}
@keyframes thinking-in {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.thinking-block { animation: none; }
}
.row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.2rem 0;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
}
.row:hover .label {
color: var(--foreground);
}
.icon {
flex-shrink: 0;
color: var(--muted-foreground);
}
.label {
font-size: 12px;
color: var(--muted-foreground);
}
.text {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.summary {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 45%;
opacity: 0.6;
}
.chev {
flex-shrink: 0;
color: var(--muted-foreground);
opacity: 0.6;
transition: transform 0.12s;
}
.chev.open {
transform: rotate(90deg);
}
.detail {
padding: 0.25rem 0 0.4rem 1.25rem;
}
.thinking-block :global(pre) {
margin: 0;
max-height: 16rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--muted);
border: 1px solid var(--border);
padding: 0.4rem 0.5rem;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--foreground);
}
@media (prefers-reduced-motion: reduce) {
.chev { transition: none; }
}
</style>

View File

@@ -1,135 +0,0 @@
<script lang="ts">
// One tool call inside AgentTrace's expanded list. Renders as a borderless
// row (the trace supplies the container/border) whose own click reveals the
// raw args/result — so the trace stays a readable thinking log by default
// and the JSON is one more click away, not stacked inline.
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
import type { ToolCallResult } from '$lib/types'
import { toolActivityLabel } from '$lib/stores/activity'
let { tool }: { tool: ToolCallResult } = $props()
let expanded = $state(false)
let liveEl = $state<HTMLPreElement | null>(null)
const status = $derived.by(() => {
if (tool.type === 'tool_use') return 'running'
if (tool.error) return 'error'
return 'done'
})
// Auto-open while a command is streaming its output, so the operator sees it
// without an extra click — mirrors UnifiedTimeline. Once the tool_result
// lands (status flips off running) liveOutput clears and the card respects
// the manual toggle again. (F4)
const open = $derived(expanded || !!tool.liveOutput)
$effect(() => {
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
})
const label = $derived(toolActivityLabel(tool))
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}`
})
const hasDetail = $derived(
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
)
</script>
<div class="tool-row">
<button
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
onclick={() => (expanded = !expanded)}
aria-expanded={open}
disabled={!hasDetail && !tool.liveOutput}
>
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
{#if status === 'running'}
<Loader2 class="size-3 animate-spin" />
{:else if status === 'error'}
<X class="size-3" />
{:else}
<Check class="size-3" />
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-xs text-foreground/90">{label}</span>
{#if argsSummary}
<span class="block truncate font-mono text-[10px] text-muted-foreground/60"
>{argsSummary}</span
>
{/if}
</span>
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
{#if hasDetail || tool.liveOutput}
<ChevronRight
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {open
? 'rotate-90'
: ''}"
/>
{/if}
</button>
{#if open}
<div class="space-y-2 px-2 pb-2 pl-7">
{#if tool.liveOutput}
<div>
<div
class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-primary"
>
<Loader2 class="size-2.5 animate-spin" />
Live output
</div>
<pre
bind:this={liveEl}
class="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/60 p-2 font-mono text-[11px] text-foreground/90">{tool.liveOutput}</pre>
</div>
{/if}
{#if tool.args}
<div>
<div
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Args
</div>
<pre
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
tool.args,
null,
2
)}</pre>
</div>
{/if}
{#if tool.result !== undefined && tool.result !== null}
<div>
<div
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Result
</div>
<pre
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
tool.result,
null,
2
)}</pre>
</div>
{/if}
{#if tool.error}
<div>
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-destructive">
Error
</div>
<pre
class="max-h-48 overflow-x-auto rounded-md border border-destructive/20 bg-destructive/5 p-2 text-[11px] text-destructive">{tool.error}</pre>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,258 @@
<script lang="ts" module>
// One tool call rendered as a compact, progressive line — the Claude-Code
// signature for the inline trace. Collapsed: state icon + humanized label +
// a one-line RESULT summary on completion (or a "live" tag while a `run`
// streams). Expanded (click): raw args/result/error in opaque <pre> blocks.
// Border-driven, square, no rounded/shadow (cyberspace system).
</script>
<script lang="ts">
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
import type { ToolCallResult } from '$lib/types'
import { toolActivityLabel, toolResultSummary } from '$lib/stores/activity'
let { tool }: { tool: ToolCallResult } = $props()
let expanded = $state(false)
let liveEl = $state<HTMLPreElement | null>(null)
const status = $derived(tool.type === 'tool_use' ? 'running' : tool.error ? 'error' : 'done')
const label = $derived(toolActivityLabel(tool))
const summary = $derived(toolResultSummary(tool))
// Tool calls start COLLAPSED — the operator expands them on demand. The
// live `run` output is shown in a separate pinned-tail mini pane below the
// collapsed row (not by auto-opening the whole detail), so the line stays
// compact while the command streams. Previously `open` auto-expanded on
// liveOutput and then collapsed when it cleared — the "start open, then
// collapse" behavior the operator found confusing.
const open = $derived(expanded)
$effect(() => {
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
})
const hasDetail = $derived(
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
)
function pretty(v: unknown): string {
if (typeof v === 'string') {
try {
return JSON.stringify(JSON.parse(v), null, 2)
} catch {
return v
}
}
try {
return JSON.stringify(v, null, 2)
} catch {
return String(v)
}
}
</script>
<div class="tool-line">
<button
class="row"
onclick={() => (expanded = !expanded)}
aria-expanded={open}
disabled={!hasDetail && !tool.liveOutput}
>
<span class="icon {status}" aria-hidden="true">
{#if status === 'running'}
<Loader2 class="size-3 animate-spin" />
{:else if status === 'error'}
<X class="size-3" />
{:else}
<Check class="size-3" />
{/if}
</span>
<span class="text min-w-0 flex-1">
<span class="label {status === 'done' ? 'done-text' : ''}">{label}</span>
</span>
{#if status === 'running' && tool.liveOutput}
<span class="live-tag"><Loader2 class="size-2.5 animate-spin" /> live</span>
{:else if status === 'done' && summary}
<span class="summary">{summary}</span>
{:else if status === 'error'}
<span class="summary err">error</span>
{/if}
{#if hasDetail || tool.liveOutput}
<ChevronRight class="chev size-3 {open ? 'open' : ''}" />
{/if}
</button>
{#if tool.liveOutput}
<!-- Live `run` output — pinned-tail mini pane, always visible while the
command streams. Separate from the expand/collapse state so the tool
line itself stays collapsed. -->
<div class="live-output">
<pre bind:this={liveEl} class="live">{tool.liveOutput}</pre>
</div>
{/if}
{#if open}
<div class="detail">
{#if tool.args}
<div class="khead">Args</div>
<pre>{pretty(tool.args)}</pre>
{/if}
{#if tool.result !== undefined && tool.result !== null}
<div class="khead">Result</div>
<pre class={status === 'error' ? 'err' : ''}>{pretty(tool.result)}</pre>
{/if}
{#if tool.error}
<div class="khead err">Error</div>
<pre class="err">{tool.error}</pre>
{/if}
</div>
{/if}
</div>
<style>
.tool-line {
border-left: 1px solid var(--border);
padding-left: 0.5rem;
animation: tool-line-in 0.15s ease-out;
}
@keyframes tool-line-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.tool-line {
animation: none;
}
}
.row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.2rem 0;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
}
.row:disabled {
cursor: default;
}
.row:not(:disabled):hover .label {
color: var(--foreground);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.75rem;
flex-shrink: 0;
}
.icon.running {
color: var(--primary);
}
.icon.error {
color: var(--destructive);
}
.icon.done {
color: var(--muted-foreground);
}
.label {
font-size: 12px;
color: var(--foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.done-text {
color: var(--muted-foreground);
}
.text {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.summary {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 45%;
}
.summary.err {
color: var(--destructive);
}
.live-tag {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--primary);
}
.chev {
flex-shrink: 0;
color: var(--muted-foreground);
opacity: 0.6;
transition: transform 0.12s;
}
.chev.open {
transform: rotate(90deg);
}
.detail {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.25rem 0 0.4rem 1.25rem;
}
.live-output {
padding: 0.1rem 0 0.3rem 1.25rem;
}
.live-output :global(pre.live) {
max-height: 8rem;
}
.khead {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
}
.khead.err {
color: var(--destructive);
}
.tool-line :global(pre) {
margin: 0;
max-height: 12rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--muted);
border: 1px solid var(--border);
padding: 0.4rem 0.5rem;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--foreground);
}
.tool-line :global(pre.err) {
color: var(--destructive);
border-color: color-mix(in oklab, var(--destructive) 40%, var(--border));
background: color-mix(in oklab, var(--destructive) 6%, var(--muted));
}
@media (prefers-reduced-motion: reduce) {
.chev {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,325 @@
<script lang="ts" module>
// The agent's working trace for ONE assistant turn, rendered inline as a
// progressive Claude-Code-style stream instead of a collapsed blob (replaces
// AgentTrace). Top to bottom: live plan checklist (running turn only), a
// "Thinking…" line while the model reasons (before the first tool / between
// steps), then each tool call as its own compact line grouped under its plan
// step. The streamed text answer is rendered by ChatThread after this.
</script>
<script lang="ts">
import type { ToolCallResult } from '$lib/types'
import type { PlanStep } from '$lib/api'
import ToolLine from './ToolLine.svelte'
import Spinner from './Spinner.svelte'
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 ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
let {
tools = [],
status = 'idle',
label = null,
isLast = false,
planSteps = [],
taskStatus
}: {
tools?: ToolCallResult[]
/** `idle` = this turn has no live state (a finished historical turn). */
status?: 'running' | 'done' | 'error' | 'idle'
/** Live indicator text while thinking (the running step / tool / "thinking…"). */
label?: string | null
isLast?: boolean
planSteps?: PlanStep[]
taskStatus?: string
} = $props()
const TERMINAL = new Set(['done', 'failed', 'abandoned'])
// seq → step title (current-gen only) so tool groups can label themselves.
const stepTitle = $derived(new Map<number, string>(planSteps.map((s) => [s.seq, s.title])))
// Group consecutive tools by their plan step (when attributed). Plan-less /
// meta tools (propose_plan, set_goal, …) have no stepSeq and form orphan
// groups rendered without a header.
interface Group {
step: { seq: number; title: string } | null
tools: ToolCallResult[]
}
const groups = $derived.by<Group[]>(() => {
const out: Group[] = []
let cur: Group | null = null
for (const t of tools) {
const seq = t.stepSeq
if (!cur || (cur.step?.seq ?? null) !== (seq ?? null)) {
cur = {
step: seq != null && stepTitle.has(seq) ? { seq, title: stepTitle.get(seq)! } : null,
tools: []
}
out.push(cur)
}
cur.tools.push(t)
}
return out
})
// Thinking line: visible while the turn is running and the model is reasoning
// — before the first tool, or after a tool finishes but before the next one
// starts. Hidden while a tool is mid-flight (its own spinner carries the
// liveness) and on idle/finished turns.
const lastToolRunning = $derived(
tools.length > 0 && tools[tools.length - 1].type === 'tool_use'
)
const showThinking = $derived(status === 'running' && !lastToolRunning)
// Plan checklist: only on the running/last turn, and only if a plan exists.
const showPlan = $derived(isLast && planSteps.length > 0)
const planTerminal = $derived(!!taskStatus && TERMINAL.has(taskStatus))
let planExpanded = $state(false)
const planDone = $derived(planSteps.filter((s) => s.status === 'done').length)
const planFailedStep = $derived(planSteps.find((s) => s.status === 'failed'))
// Elapsed time on the running step — ticks every second while a step is
// running so the operator can see how long it's been going (and spot a
// stuck step).
let now = $state(Date.now())
$effect(() => {
const running = planSteps.some((s) => s.status === 'running')
if (!running) return
const id = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(id)
})
function stepElapsed(s: PlanStep): string {
if (s.status !== 'running' || !s.started_at) return ''
const sec = Math.max(0, Math.floor((now - new Date(s.started_at).getTime()) / 1000))
if (sec < 60) return `${sec}s`
if (sec < 3600) return `${Math.floor(sec / 60)}m`
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
}
</script>
{#if showPlan}
<div class="plan {planTerminal && !planExpanded ? 'plan-collapsed' : ''}">
{#if planTerminal && !planExpanded}
<button class="plan-summary" onclick={() => (planExpanded = true)}>
{#if planFailedStep}
<XIcon class="size-3 text-destructive" />
<span class="plan-summary-text">Plan failed — step {planFailedStep.seq}</span>
{:else}
<CheckIcon class="size-3 text-primary" />
<span class="plan-summary-text">Plan complete — {planDone}/{planSteps.length} steps</span>
{/if}
<ChevronRightIcon class="size-3 text-muted-foreground/60" />
</button>
{:else}
<div class="plan-head">
<span class="plan-head-label">Plan</span>
<span class="plan-head-count">{planDone}/{planSteps.length}</span>
</div>
<ul class="plan-list">
{#each planSteps as s (s.id)}
<li class="plan-step {s.status === 'running' ? 'running' : ''}">
<span class="plan-node {s.status}" aria-hidden="true">
{#if s.status === 'running'}<Spinner class="size-3 text-primary" />
{:else if s.status === 'done'}<CheckIcon class="size-2.5" strokeWidth={3.5} />
{:else if s.status === 'failed'}<XIcon class="size-2.5" strokeWidth={3.5} />
{:else if s.status === 'blocked'}<PauseIcon class="size-2" strokeWidth={3} />
{:else if s.status === 'skipped' || s.status === 'replaced'}<SlashIcon class="size-2" strokeWidth={3} />
{/if}
</span>
<span class="plan-title" title={s.title}>{s.title}</span>
{#if s.status === 'running'}
<span class="plan-elapsed">{stepElapsed(s)}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<!-- Thinking line: always rendered (fixed height) so appearing/disappearing
doesn't shift the layout — it just fades in/out. Shows a STABLE label
("Working…") rather than the current operation, which would rewrite
itself on every step/tool transition and read as text appearing and
disappearing. The current operation is already visible in the plan
checklist (running step) and the tool lines below. -->
<div class="thinking {showThinking ? '' : 'thinking-hidden'}" aria-hidden={!showThinking}>
<Spinner class="size-3 shrink-0 text-primary" />
<span class="thinking-text">Working…</span>
</div>
{#if groups.length > 0}
<div class="tools">
{#each groups as g, gi (gi)}
{#if g.step}
<div class="step-head">Step {g.step.seq} · {g.step.title}</div>
{/if}
{#each g.tools as tool (tool.id ?? `${gi}-${tool.name}`)}
<ToolLine {tool} />
{/each}
{/each}
</div>
{:else if status === 'idle' && tools.length === 0}
<!-- finished turn with no tools: nothing to render -->
{:else if status === 'error'}
<div class="thinking err">
<XIcon class="size-3 shrink-0 text-destructive" />
<span class="thinking-text">{label || 'Turn ended with an error'}</span>
</div>
{/if}
<style>
.plan {
border: 1px solid var(--border);
background: color-mix(in oklab, var(--primary) 3%, var(--card));
padding: 0.35rem 0.55rem 0.4rem;
margin-bottom: 0.35rem;
animation: plan-in 0.2s ease-out;
}
@keyframes plan-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.plan {
animation: none;
}
}
.plan-summary {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
text-align: left;
}
.plan-summary-text {
font-size: 12px;
color: var(--foreground);
flex: 1;
}
.plan-head {
display: flex;
align-items: baseline;
gap: 0.4rem;
margin-bottom: 0.25rem;
}
.plan-head-label {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted-foreground);
}
.plan-head-count {
font-family: var(--font-mono);
font-size: 10px;
color: var(--primary);
}
.plan-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.plan-step {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.1rem 0;
}
.plan-step.running {
background: color-mix(in oklab, var(--primary) 8%, transparent);
margin: 0 -0.3rem;
padding-left: 0.3rem;
padding-right: 0.3rem;
}
.plan-node {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
color: var(--muted-foreground);
}
.plan-node.done {
color: var(--primary);
}
.plan-node.failed {
color: var(--destructive);
}
.plan-node.running {
color: var(--primary);
}
.plan-title {
font-size: 12px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-step.running .plan-title {
color: var(--foreground);
font-weight: 500;
}
.plan-elapsed {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 10px;
color: var(--primary);
margin-left: auto;
}
.thinking {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
height: 1.5rem;
overflow: hidden;
opacity: 1;
transition: opacity 0.2s ease;
}
.thinking-hidden {
opacity: 0;
pointer-events: none;
}
.thinking.err {
color: var(--destructive);
}
.thinking-text {
font-size: 12px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tools {
display: flex;
flex-direction: column;
}
.step-head {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 0.35rem 0 0.1rem;
}
</style>

View File

@@ -1,585 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte'
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'
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
import { openEntityWindow } from '$lib/stores/windows'
// Merged plan + activity timeline, designed for the narrow rail:
// - ordered newest-first: what the agent is doing right now is at the top,
// history flows downward, and the goal sits at the bottom where the task
// began (see the sort in `items`)
// - 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 down into history, 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)
}
// Pin each streaming output pane to its tail as chunks arrive. Keyed by
// tool id because several run entries can be on screen, though only the
// newest one is ever actually streaming.
let liveOutputEls = $state<Record<string, HTMLPreElement | null>>({})
$effect(() => {
// Depend on entries only. liveOutputEls is written by bind:this, so
// tracking it here would let a re-render re-trigger this effect.
const current = entries
untrack(() => {
for (const e of current) {
if (!e.liveOutput) continue
const el = liveOutputEls[e.id]
if (el) el.scrollTop = el.scrollHeight
}
})
})
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. ts 0
// parks them at the tail of the newest-first sort below (see there).
if (steps.length > 0) {
out.push({ kind: 'step', step: s, tools: [], ts: 0 })
}
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)
// Timed from the step's own entry, else its earliest tool — so a step
// is placed by when it started, not by its latest activity.
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
// Tools inside a step run newest-first too, matching the outer order.
out.push({ kind: 'step', step: s, tools: [...tools].reverse(), 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 })
}
// Newest first: whatever the agent is doing right now sits at the top of
// the rail, with history flowing downward. The two ts-0 groups fall to
// the bottom for free, which is where both belong in this order: the goal
// (timestamp 0 — where the task started) and not-yet-run plan steps.
// Sorting the latter by their future position would put them *above* the
// running step and push it off the top, which is exactly what this
// ordering exists to prevent. Array.sort is stable, so each group keeps
// its insertion order (plan steps in seq order).
out.sort((a, b) => b.ts - a.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)
// Newest-first, so "following the agent" means being parked at the top —
// the mirror of the bottom-anchored follow this had when it ran oldest-first.
function onScroll() {
if (!container) return
follow = container.scrollTop < 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 = 0
})
// ── 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) || !!tool.liveOutput}
<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 ||
tool.liveOutput
? 'cursor-pointer hover:bg-muted/20'
: 'cursor-default'}"
onclick={() =>
(tool.args || tool.detail || tool.liveOutput) && 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>
{#if !tool.link}
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
>{hhmm(tool.timestamp)}</span
>
{/if}
</button>
{#if tool.link}
<button
type="button"
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
title="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
aria-label="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
onclick={() => openEntityWindow(tool.link!.slug)}
>
<ExternalLinkIcon class="size-3" />
</button>
{/if}
{#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.liveOutput}
<!-- Streaming while the command runs. Bound so it
can be pinned to the tail as chunks arrive. -->
<pre
bind:this={liveOutputEls[tool.id]}
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">{tool.liveOutput}</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>
{#if !e.link}
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
>{hhmm(e.timestamp)}</span
>
{/if}
</button>
{#if e.link}
<button
type="button"
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
title="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
aria-label="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
onclick={() => openEntityWindow(e.link!.slug)}
>
<ExternalLinkIcon class="size-3" />
</button>
{/if}
{#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

@@ -13,6 +13,7 @@
wm,
dk,
wmState,
windowKeys,
openEntityWindow,
NEW_TASK_WINDOW_ID,
SESSION_PREFIX,
@@ -59,7 +60,7 @@
</script>
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
{#each $wmState.order as id (id)}
{#each $windowKeys as id (id)}
{@const win = $wmState.windows[id]}
{@const appId = appIdFromWindowId(id)}
{@const app = appId ? $appById.get(appId) : undefined}

View File

@@ -25,7 +25,7 @@ vi.mock('./execstream', () => ({
liveExecutionOutputFor: vi.fn(() => writable(null))
}))
import { computeActivityLog } from './activity'
import { computeActivityLog, toolResultSummary } from './activity'
import type { ChatMessage } from './chat'
import type { PlanStep, Session } from '$lib/api'
@@ -172,3 +172,76 @@ describe('computeActivityLog generation awareness (F4)', () => {
expect(entries.find((e) => e.id === 't1')!.stepSeq).toBeUndefined()
})
})
// toolResultSummary (chat interaction overhaul): a one-line, humanized outcome
// per tool so each inline tool line reads as a result instead of raw JSON.
describe('toolResultSummary', () => {
type TR = NonNullable<ChatMessage['tools']>[number]
const done = (name: string, result: unknown, args?: Record<string, unknown>): TR => ({
type: 'tool_result',
name,
id: name,
result,
args
})
it('is empty for a still-running call and for an errored one', () => {
expect(toolResultSummary({ type: 'tool_use', name: 'run', id: 'r' })).toBe('')
expect(
toolResultSummary({ type: 'tool_result', name: 'run', id: 'r', error: 'boom' })
).toBe('')
})
it('parses run exit status', () => {
expect(
toolResultSummary(done('run', 'run on lxc:caddy: ERROR exit status 1'))
).toContain('exit 1')
})
it('summarizes a clean run with its first line', () => {
const s = toolResultSummary(done('run', 'Active: active (running)'))
expect(s.startsWith('ok')).toBe(true)
expect(s).toContain('active')
})
it('formats get_entity as slug (health)', () => {
expect(
toolResultSummary(done('get_entity', { slug: 'host:hubris', health: 'healthy' }))
).toBe('host:hubris (healthy)')
})
it('counts list results', () => {
expect(
toolResultSummary(done('list_entities', { entities: Array(10).fill({}) }))
).toBe('10 entities')
expect(toolResultSummary(done('list_lxcs', { containers: [1, 2] }))).toBe('2 containers')
})
it('formats fleet health counts', () => {
expect(
toolResultSummary(
done('get_health_summary', { health: { healthy: 5, degraded: 1, down: 0, unknown: 2 } })
)
).toBe('healthy 5 · degraded 1 · down 0 · unknown 2')
})
it('extracts the knowledge slug from upsert_knowledge', () => {
expect(
toolResultSummary(done('upsert_knowledge', 'Saved document:nomos/foo-bar to the DB'))
).toBe('recorded document:nomos/foo-bar')
})
it('formats update_plan_step from args', () => {
expect(
toolResultSummary(done('update_plan_step', 'ok', { seq: 2, status: 'done' }))
).toBe('step 2 → done')
})
it('counts proposed plan steps', () => {
expect(toolResultSummary(done('propose_plan', { steps: [{}, {}, {}] }))).toBe('3 steps')
})
it('falls back to the first line for unmapped tools', () => {
expect(toolResultSummary(done('some_new_tool', 'first line\nsecond line'))).toBe('first line')
})
})

View File

@@ -433,3 +433,148 @@ export function toolActivityLabel(t: ToolCallResult): string {
return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
}
}
// ── toolResultSummary ────────────────────────────────────────────────────
// A one-line, humanized summary of a tool's RESULT (the Claude-Code-style
// "exit 0 · <line>" / "host:hubris (healthy)" affordance) so each tool line
// in the inline trace reads as an outcome instead of a raw JSON blob. Empty
// for a still-running call (no result yet) or an errored one (the error is
// surfaced separately). Best-effort by tool name; the fallback is the first
// non-empty line of the stringified result, truncated — never blank (the
// expandable raw detail is always one click away).
function resultAsString(r: unknown): string {
if (r == null) return ''
if (typeof r === 'string') return r
try {
return JSON.stringify(r)
} catch {
return String(r)
}
}
function firstLine(s: string, max = 80): string {
const line = s
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.length > 0) ?? ''
return line.length > max ? `${line.slice(0, max - 1)}` : line
}
function resultArray(r: unknown): unknown[] | null {
if (Array.isArray(r)) return r
if (r && typeof r === 'object') {
const o = r as Record<string, unknown>
for (const k of [
'entities',
'results',
'relations',
'steps',
'items',
'containers',
'docs',
'questions',
'signals',
'events',
'patterns',
'skills'
]) {
if (Array.isArray(o[k])) return o[k] as unknown[]
}
}
return null
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
return `${n} ${n === 1 ? singular : pluralForm}`
}
export function toolResultSummary(t: ToolCallResult): string {
if (t.type === 'tool_use') return '' // still running
if (t.error) return '' // error surfaced separately
const args = t.args ?? {}
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const obj = (r: unknown): Record<string, unknown> | null =>
r && typeof r === 'object' && !Array.isArray(r) ? (r as Record<string, unknown>) : null
switch (t.name) {
case 'run': {
const s = resultAsString(t.result)
const m = s.match(/exit (?:status )?(\d+)/i)
const tag = m ? `exit ${m[1]}` : /error/i.test(s) ? 'error' : 'ok'
const rest = firstLine(s.replace(/[\s\S]*exit (?:status )?\d+/i, ''), 60)
return rest ? `${tag} · ${rest}` : tag
}
case 'get_entity': {
const o = obj(t.result)
const slug = str(o?.slug) || str(args.slug_or_id)
const health = str(o?.health) || str(o?.state)
return [slug, health && `(${health})`].filter(Boolean).join(' ') || 'found'
}
case 'get_relations': {
const a = resultArray(t.result)
return a ? plural(a.length, 'relation') : 'done'
}
case 'list_entities':
case 'list_lxcs': {
const a = resultArray(t.result)
if (!a) return 'done'
return t.name === 'list_lxcs'
? plural(a.length, 'container')
: plural(a.length, 'entity', 'entities')
}
case 'get_health_summary': {
const o = obj(t.result)
const h = (o?.health && obj(o.health)) || o
if (h) {
const parts = ['healthy', 'degraded', 'down', 'unknown']
.map((k) => {
const n = num((h as Record<string, unknown>)[k])
return n != null ? `${k} ${n}` : null
})
.filter((p): p is string => p != null)
if (parts.length) return parts.join(' · ')
}
return 'done'
}
case 'get_state_snapshot': {
const o = obj(t.result)
const drift = num(o?.drift ?? o?.drift_count)
return drift != null ? plural(drift, 'drift') : 'done'
}
case 'search_knowledge':
case 'get_entity_knowledge':
case 'get_patterns':
case 'get_skills': {
const a = resultArray(t.result)
return a ? plural(a.length, 'result') : firstLine(resultAsString(t.result)) || 'done'
}
case 'upsert_knowledge': {
const m = resultAsString(t.result).match(/[a-z]+:nomos\/[a-z0-9-]+/)
return m ? `recorded ${m[0]}` : 'recorded'
}
case 'update_plan_step': {
const seq = num(args.seq)
const status = str(args.status)
if (seq != null && status) return `step ${seq}${status}`
return status || 'updated'
}
case 'propose_plan': {
const a = resultArray(t.result) ?? resultArray(args.steps)
return a ? plural(a.length, 'step') : 'planned'
}
case 'set_goal':
return 'goal set'
case 'complete_task':
return str(args.outcome) || 'complete'
case 'ask_operator':
return 'asked'
case 'ping_service': {
const s = resultAsString(t.result).toLowerCase()
return /ok|reachable|up|healthy/.test(s) ? 'reachable' : firstLine(s, 40) || 'done'
}
case 'get_execution_status': {
const o = obj(t.result)
return str(o?.state) || firstLine(resultAsString(t.result), 40) || 'done'
}
default:
return firstLine(resultAsString(t.result)) || 'done'
}
}

View File

@@ -25,6 +25,7 @@ export interface ChatMessage {
id: string
role: 'user' | 'assistant'
text: string
thinking?: string
tools: ToolCallResult[]
pendingApprovals: PendingApproval[]
created_at?: string
@@ -223,6 +224,7 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
id: m.id,
role: m.role as 'user' | 'assistant',
text: content?.text ?? '',
thinking: content?.thinking ?? undefined,
tools,
pendingApprovals: extractApprovals(tools),
created_at: m.created_at
@@ -230,6 +232,16 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
})
}
function chatMessagesChanged(a: ChatMessage[], b: ChatMessage[]): boolean {
if (a.length !== b.length) return true
for (let i = 0; i < a.length; i++) {
if (a[i].id !== b[i].id || a[i].role !== b[i].role || a[i].text !== b[i].text || a[i].tools.length !== b[i].tools.length) {
return true
}
}
return false
}
export async function loadSessionMessages(sessionId: string) {
currentSession.set(sessionId)
// This is a fresh view of sessionId's current (REST-loaded) state — reset
@@ -270,15 +282,11 @@ function startPolling(sessionId: string) {
if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return
const msgs = await fetchMessages(sessionId)
if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time
// No cheap "anything new?" check: the auto-continuation worker updates a
// placeholder message IN PLACE as each tool call lands (see
// cmd/nomos/continue.go), so the message COUNT stays the same while the
// content changes — a length-only diff (the previous version of this
// code) never detected those updates and progress looked frozen even
// though the backend was actively working. Just re-set every tick;
// Svelte's own diffing keeps the actual re-render cheap.
sessionMessages.set(msgs)
messages.set(toChatMessages(msgs))
const incoming = toChatMessages(msgs)
if (chatMessagesChanged(get(messages), incoming)) {
sessionMessages.set(msgs)
messages.set(incoming)
}
}, 3000)
}
@@ -401,7 +409,15 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
if (ev.is_thinking) {
ms[ms.length - 1] = {
...last,
thinking: (last.thinking || '') + ev.data,
text: ''
}
} else {
ms[ms.length - 1] = { ...last, text: ev.data }
}
}
return [...ms]
})
@@ -596,7 +612,10 @@ function startSessionPolling(sessionId: string) {
if (get(chat.streaming) && get(chat.connectionState) === 'connected') return
const msgs = await fetchMessages(sessionId)
if (get(chat.streaming)) return // re-check: the fetch itself takes time
chat.messages.set(toChatMessages(msgs))
const incoming = toChatMessages(msgs)
if (chatMessagesChanged(get(chat.messages), incoming)) {
chat.messages.set(incoming)
}
// F3 safety net: if we're recovering from a dropped SSE but the
// session's task has already reached a turn-ended status, clear the
// stuck disconnected/streaming flags. Catches the edge where the
@@ -728,7 +747,15 @@ export function sendSessionMessage(sessionId: string, text: string) {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
if (ev.is_thinking) {
ms[ms.length - 1] = {
...last,
thinking: (last.thinking || '') + ev.data,
text: ''
}
} else {
ms[ms.length - 1] = { ...last, text: ev.data }
}
}
return [...ms]
})
@@ -869,7 +896,15 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
if (ev.is_thinking) {
ms[ms.length - 1] = {
...last,
thinking: (last.thinking || '') + ev.data,
text: ''
}
} else {
ms[ms.length - 1] = { ...last, text: ev.data }
}
}
return [...ms]
})

View File

@@ -51,6 +51,22 @@ export const dk = createDesktop(wm, {
})
export const wmState = wmStore(wm)
// Stable insertion-order window IDs — unlike $wmState.order (which reorders on
// focus/raise), this only changes when a window is opened or closed. Used by
// WindowLayer's {#each} so the DOM order stays stable; wmkit handles visual
// stacking via z-index in syncAll(). Without this, every focus change moves
// <section> elements in the DOM, which resets scroll positions of scrollable
// children in Chrome.
let _lastKeys: string[] = []
export const windowKeys = derived(wmState, ($s) => {
const keys = Object.keys($s.windows)
if (keys.length === _lastKeys.length && keys.every((k, i) => k === _lastKeys[i])) {
return _lastKeys
}
_lastKeys = keys
return keys
})
// The session id backing whichever task/chat window currently has focus, or
// null when no task window is focused (Tasks app, an entity window, or
// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so

View File

@@ -26,6 +26,7 @@ export interface ChatTextDeltaEvent {
export interface ChatTextEvent {
type: 'text'
data: string
is_thinking?: boolean
}
export interface ChatDoneEvent {
@@ -71,12 +72,18 @@ export interface ToolCallResult {
// running, so the tool card can show output as it arrives instead of all at
// once when the tool_result lands. Not present on persisted/historical calls.
liveOutput?: string
// Plan step this call belongs to (current generation only). Attached by
// ChatThread from the activity log so the inline trace can group a turn's
// tool calls under their step. Undefined for orphan calls (no plan / older
// generation / plan-less Q&A).
stepSeq?: number
}
// ---- Message content (persisted messages from /agent/sessions/:id) ----
export interface MessageContent {
text?: string
thinking?: string
tool_calls?: ToolCallResult[]
}