diff --git a/ui/src/lib/panel/activity.svelte.ts b/ui/src/lib/panel/activity.svelte.ts index 92dddad..422fa32 100644 --- a/ui/src/lib/panel/activity.svelte.ts +++ b/ui/src/lib/panel/activity.svelte.ts @@ -30,6 +30,8 @@ export interface Turn { // A tool call came between two pieces of text; the next piece starts a // paragraph, as the final message will show it. paused: boolean; + // What the model thought, when its thinking is reasoning and not progress. + reasoning: string; resultSubtype: string | null; roots: string[]; says: string[]; @@ -223,25 +225,45 @@ export class ActivityModel { const turn = this.turnFor(turnId, ts, parent); const sdk = raw as Record; if (sdk.type === "content_block_start") { - const block = sdk.content_block as Record | undefined; - if (block?.type === "tool_use" && typeof block.id === "string") { - this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts); - } else if (block?.type === "thinking" && parent === null) { - turn.thinking += 1; + this.onBlockStart(turn, sdk.content_block, parent, ts); + } else if (sdk.type === "content_block_delta" && parent === null) { + this.onDelta(turn, sdk.delta as Record | undefined); + } + } + + private onBlockStart( + turn: Turn, + raw: unknown, + parent: string | null, + ts: string + ): void { + const block = raw as Record | undefined; + if (block?.type === "tool_use" && typeof block.id === "string") { + this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts); + } else if (block?.type === "thinking" && parent === null) { + turn.thinking += 1; + if (turn.reasoning) { + turn.reasoning += "\n\n"; } + } + } + + private onDelta( + turn: Turn, + delta: Record | undefined + ): void { + const thought = delta?.type === "thinking_delta" ? delta.thinking : null; + if ( + typeof thought === "string" && + this.conversation?.thinking_is_progress !== true + ) { + turn.reasoning += thought; return; } - if (sdk.type === "content_block_delta" && parent === null) { - const delta = sdk.delta as Record | undefined; - const progress = this.conversation?.thinking_is_progress === true; - const text = - delta?.type === "thinking_delta" && progress - ? delta.thinking - : delta?.text; - if (typeof text === "string" && text) { - turn.text = joinText(turn.text, text, turn.paused); - turn.paused = false; - } + const text = thought ?? delta?.text; + if (typeof text === "string" && text) { + turn.text = joinText(turn.text, text, turn.paused); + turn.paused = false; } } @@ -365,6 +387,7 @@ export class ActivityModel { nodes: {}, origin: origin ?? "?", paused: false, + reasoning: "", resultSubtype: null, roots: [], says: [], diff --git a/ui/src/lib/panel/chat-view.svelte b/ui/src/lib/panel/chat-view.svelte index c5cff5d..01ce4c7 100644 --- a/ui/src/lib/panel/chat-view.svelte +++ b/ui/src/lib/panel/chat-view.svelte @@ -6,12 +6,16 @@ import ErrorNote from "$lib/components/error-note.svelte"; import { clip, fmtDateTime, fmtTime } from "$lib/format"; import { cn } from "$lib/utils"; - import type { ActivityModel } from "./activity.svelte"; - import { isLive, summarizeInput, toolLabel } from "./activity.svelte"; + import type { ActivityModel, ToolNode } from "./activity.svelte"; + import { isLive } from "./activity.svelte"; import { cacheableHistory, historyKey } from "./history-cache"; import { usePanelHost } from "./host"; import Markdown from "./markdown.svelte"; import QuestionCard from "./question-card.svelte"; + import Reasoning from "./reasoning.svelte"; + import SayBubble from "./say-bubble.svelte"; + import ServiceLine from "./service-line.svelte"; + import ToolNodeView from "./tool-node.svelte"; import TurnCard from "./turn-card.svelte"; let { @@ -26,25 +30,42 @@ refreshKey?: number; } = $props(); - const RESULT_CLIP = 600; - // Markdown for the last few screens renders at once; the rest waits - // until scrolled near, so a long thread paints as fast as a short one. - const EAGER_MARKDOWN = 40; + interface Step { + block: ContentBlock; + eager: boolean; + key: string; + stampedAt: string | null; + } + + type Row = + | { day: string; key: string; kind: "day" } + | { key: string; kind: "system"; text: string; ts: string | null } + | { + eager: boolean; + key: string; + kind: "user"; + service: string | null; + texts: string[]; + ts: string | null; + } + | { key: string; kind: "agent"; steps: Step[] }; + + const RESULT_CLIP = 20_000; + const RENDERED_AT_ONCE = 40; const host = usePanelHost(); - const NEAR_BOTTOM_PX = 120; + const AT_BOTTOM_PX = 8; const TICK_MS = 1000; + const NO_NODES: Record = {}; let messages = $state(null); let failure = $state(null); - let showResults = $state(false); let loadedAt = $state(new Date(0).toISOString()); let scroller = $state(null); let body = $state(null); - const SETTLE_MS = 2500; - const SETTLE_STEP_MS = 120; - let settleUntil = 0; let now = $state(Date.now()); let pinned = true; + let readerRow: { node: Element; top: number } | null = null; + let ownScroll = false; const tail = $derived( model.turns @@ -53,7 +74,11 @@ ); const tailSize = $derived( tail.reduce( - (n, turn) => n + turn.text.length + Object.keys(turn.nodes).length, + (n, turn) => + n + + turn.text.length + + turn.reasoning.length + + Object.keys(turn.nodes).length, 0 ) ); @@ -71,46 +96,61 @@ try { ({ messages } = await client.history(conversationId)); loadedAt = new Date().toISOString(); - settle(); host.cache?.set(historyKey(conversationId), cacheableHistory(messages)); } catch (cause) { failure = cause instanceof Error ? cause.message : String(cause); } } - // The scroller is column-reverse: its origin is the bottom, scrollTop is - // zero there and goes negative upward, and the browser keeps the view at - // the bottom while content grows - no matter how late markdown lands. - function nearBottom(): boolean { - return !scroller || Math.abs(scroller.scrollTop) < NEAR_BOTTOM_PX; - } - function toBottom() { if (scroller) { scroller.scrollTop = 0; } } - async function scrollToBottom() { - await tick(); - toBottom(); - } - - // A fresh thread keeps growing after it is on screen: markdown renders - // late, embeds load, fonts land. Hold the bottom for a while after a load. - function settle() { - settleUntil = Date.now() + SETTLE_MS; - const step = () => { - if (!pinned || Date.now() > settleUntil) { - return; + function topRow(): { node: Element; top: number } | null { + if (!(scroller && body)) { + return null; + } + const edge = scroller.getBoundingClientRect().top; + const rows = body.children; + let low = 0; + let high = rows.length - 1; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (rows[middle].getBoundingClientRect().bottom > edge) { + high = middle; + } else { + low = middle + 1; } + } + const node = rows[low]; + return node ? { node, top: node.getBoundingClientRect().top } : null; + } + + function trackReader() { + if (ownScroll) { + ownScroll = false; + return; + } + pinned = !scroller || Math.abs(scroller.scrollTop) < AT_BOTTOM_PX; + readerRow = pinned ? null : topRow(); + } + + function keepReaderRowInPlace() { + if (pinned) { toBottom(); - setTimeout(step, SETTLE_STEP_MS); - }; - step(); + return; + } + if (!(scroller && readerRow?.node.isConnected)) { + return; + } + const drift = readerRow.node.getBoundingClientRect().top - readerRow.top; + const before = scroller.scrollTop; + scroller.scrollTop += drift; + ownScroll = scroller.scrollTop !== before; } - // The date changes rarely; say it once, then only the time. function dayOf(iso: string | null | undefined): string { return iso ? iso.slice(0, 10) : ""; } @@ -121,7 +161,6 @@ $effect(() => { if (refreshKey >= 0) { - pinned = true; untrack(() => load()); } }); @@ -129,21 +168,15 @@ $effect(() => { const count = (messages?.length ?? 0) + tailSize; if (pinned && count >= 0) { - scrollToBottom(); + tick().then(toBottom); } }); - // Markdown lands asynchronously (Obsidian renders after the DOM exists), - // so follow the content's height, not just the message count. $effect(() => { if (!(scroller && body)) { return; } - const observer = new ResizeObserver(() => { - if (pinned && scroller) { - scroller.scrollTop = scroller.scrollHeight; - } - }); + const observer = new ResizeObserver(keepReaderRowInPlace); observer.observe(body); return () => observer.disconnect(); }); @@ -157,36 +190,13 @@ return () => clearInterval(timer); }); - // Text the gateway or the CLI put in the user's seat: an inject header, - // an envelope, a subagent's report. const SYSTEM_HEAD = /^(\[[^\]\n]+\]|)/; const SECONDS = /:\d{2}$/; const DAY_TIME = /,?\s*\d{2}:\d{2}$/; - let openSystem = $state>(new Set()); - function systemText(message: HistoryMessage): string | null { - if (message.role !== "user" || typeof message.content !== "string") { - return null; - } - return SYSTEM_HEAD.test(message.content.trimStart()) - ? message.content - : null; - } - - function toggleSystem(index: number) { - const next = new Set(openSystem); - if (next.has(index)) { - next.delete(index); - } else { - next.add(index); - } - openSystem = next; - } - - // Keys count from the end: when the network copy grows the head past - // what the cache held, the tail keeps its nodes and its rendered markdown. function keyOf(message: HistoryMessage, index: number, total: number) { - return `${index - total}:${message.role}:${message.ts ?? ""}`; + const fromEnd = `${index - total}:${message.role}:${message.ts ?? ""}`; + return message.id ?? fromEnd; } function blocks(message: HistoryMessage): ContentBlock[] { @@ -222,28 +232,127 @@ } return ""; } + + const results = $derived.by(() => { + const found = new Map(); + for (const message of messages ?? []) { + if (typeof message.content === "string") { + continue; + } + for (const block of message.content) { + if (block.type === "tool_result" && block.tool_use_id) { + found.set(block.tool_use_id, { + failed: block.is_error === true, + text: clip(resultText(block), RESULT_CLIP), + }); + } + } + } + return found; + }); + + function nodeOf(block: ContentBlock): ToolNode { + const result = block.id ? results.get(block.id) : undefined; + let status: ToolNode["status"] = "aborted"; + if (result) { + status = result.failed ? "error" : "done"; + } + return { + children: [], + endedAt: null, + id: block.id ?? "", + input: block.input ?? null, + name: block.name ?? "?", + parent: null, + result: result?.text ?? "", + startedAt: "", + status, + }; + } + + function said(block: ContentBlock): string | null { + const text = (block.input as { text?: unknown } | null | undefined)?.text; + return block.name?.endsWith("__say") && typeof text === "string" + ? text + : null; + } + + function userRow( + message: HistoryMessage, + parts: ContentBlock[], + key: string, + eager: boolean + ): Row { + const ts = message.ts ?? null; + const texts = parts.flatMap((b) => + b.type === "text" && b.text ? [b.text] : [] + ); + if (!message.service && SYSTEM_HEAD.test(texts[0]?.trimStart() ?? "")) { + return { key, kind: "system", text: texts.join("\n"), ts }; + } + const service = message.service ?? null; + return { eager, key, kind: "user", service, texts, ts }; + } + + function stepsOf( + message: HistoryMessage, + parts: ContentBlock[], + key: string, + eager: boolean, + minute: string + ): Step[] { + const ts = message.ts ?? null; + const stamped = + minuteOf(ts) !== minute || + parts.some((b) => b.type === "text" && Boolean(b.text)); + return parts.map((block, at) => ({ + block, + eager, + key: `${key}:${at}`, + stampedAt: stamped && at === 0 ? ts : null, + })); + } + + const rows = $derived.by(() => { + const out: Row[] = []; + const list = messages ?? []; + let run: Step[] | null = null; + let day = ""; + let minute = ""; + for (const [index, message] of list.entries()) { + const parts = blocks(message); + if (parts.every((b) => b.type === "tool_result")) { + continue; + } + const key = keyOf(message, index, list.length); + const eager = index >= list.length - RENDERED_AT_ONCE; + if (message.ts && dayOf(message.ts) !== day) { + day = dayOf(message.ts); + const label = fmtDateTime(message.ts).replace(DAY_TIME, ""); + out.push({ day: label, key, kind: "day" }); + run = null; + } + if (message.role === "user") { + out.push(userRow(message, parts, key, eager)); + run = null; + } else if (run === null) { + run = stepsOf(message, parts, key, eager, minute); + out.push({ key, kind: "agent", steps: run }); + } else { + run.push(...stepsOf(message, parts, key, eager, minute)); + } + minute = message.ts ? minuteOf(message.ts) : minute; + } + return out; + });
{ - if (Date.now() > settleUntil) { - pinned = nearBottom(); - } - }} + onscroll={trackReader} bind:this={scroller} >
- {#if failure} {:else if messages === null} @@ -256,97 +365,72 @@ title="Empty conversation" /> {:else} - {#each messages as message, index (keyOf(message, index, messages.length))} - {@const parts = blocks(message)} - {@const isResultOnly = parts.every((b) => b.type === "tool_result")} - {@const system = systemText(message)} - {@const newDay = - message.ts && dayOf(message.ts) !== dayOf(messages[index - 1]?.ts)} - {@const newMinute = - message.ts && - minuteOf(message.ts) !== minuteOf(messages[index - 1]?.ts)} - {@const spoken = - message.role === "user" || - parts.some((b) => b.type === "text" && Boolean(b.text))} - {#if newDay} -

- {fmtDateTime(message.ts).replace(DAY_TIME, "")} -

- {/if} - {#if system} + {#each rows as row (`${row.kind}:${row.key}`)} + {#if row.kind === "day"} +

{row.day}

+ {:else if row.kind === "system"}
- {#if message.ts} + {#if row.ts} - {minuteOf(message.ts)} + {minuteOf(row.ts)} {/if} - +
- {:else if parts.length > 0 && !(isResultOnly && !showResults)} -
- {#if message.ts && (newMinute || spoken) && !isResultOnly} + {:else if row.kind === "user"} +
+ {#if row.ts} - {minuteOf(message.ts)} + {minuteOf(row.ts)} {/if} - {#each parts as block, blockIndex (blockIndex)} + {#each row.texts as text, index (index)} + + {/each} + {#if row.service} + + {/if} +
+ {:else} +
+ {#each row.steps as step (step.key)} + {@const block = step.block} + {#if step.stampedAt} + + {minuteOf(step.stampedAt)} + + {/if} {#if block.type === "text" && block.text} = messages.length - EAGER_MARKDOWN} + class="min-w-0 @md:max-w-[75ch] max-w-full px-1 py-1" + eager={step.eager} text={block.text} /> {:else if block.type === "tool_use"} -

- {toolLabel(block.name ?? "?")} - - {clip(summarizeInput(block.name ?? "", block.input), 160)} - -

- {:else if block.type === "tool_result" && showResults} -
{clip(resultText(block), RESULT_CLIP)}
+ {@const spoken = said(block)} + {#if spoken} +
+ {:else} + + {/if} + {:else if block.type === "thinking" && block.thinking?.trim()} + {:else if block.type === "thinking"} -

thinking

+

thinking

{/if} {/each}
diff --git a/ui/src/lib/panel/fences.ts b/ui/src/lib/panel/fences.ts new file mode 100644 index 0000000..383690c --- /dev/null +++ b/ui/src/lib/panel/fences.ts @@ -0,0 +1,18 @@ +const FENCE_HEAD = + /^((?:\s*(?:[-+*]|\d+[.)])\s+|\s*>)*\s*)(`{3,}|~{3,})[ \t]*([^\s`]+)(.*)$/gm; + +export function inertFences( + text: string, + runs: (language: string) => boolean +): string { + return text.replace( + FENCE_HEAD, + ( + line: string, + lead: string, + fence: string, + language: string, + rest: string + ) => (runs(language.toLowerCase()) ? `${lead}${fence}text${rest}` : line) + ); +} diff --git a/ui/src/lib/panel/host.ts b/ui/src/lib/panel/host.ts index eac9242..29ba18d 100644 --- a/ui/src/lib/panel/host.ts +++ b/ui/src/lib/panel/host.ts @@ -11,7 +11,11 @@ export interface PanelHost { // Where the index and the last threads persist between opens. cache?: PanelCache; // Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify. - markdown?: (node: HTMLElement, text: string) => (() => void) | undefined; + markdown?: ( + node: HTMLElement, + text: string, + options?: { inert?: boolean } + ) => (() => void) | undefined; name: "browser" | "obsidian"; openLink: (target: string, kind: LinkKind) => void; // Opens the note a conversation lives in (its markdown binding). diff --git a/ui/src/lib/panel/markdown.svelte b/ui/src/lib/panel/markdown.svelte index d194e99..3bb3da1 100644 --- a/ui/src/lib/panel/markdown.svelte +++ b/ui/src/lib/panel/markdown.svelte @@ -7,7 +7,13 @@ text, class: className = "", eager = true, - }: { text: string; class?: string; eager?: boolean } = $props(); + inert = false, + }: { + text: string; + class?: string; + eager?: boolean; + inert?: boolean; + } = $props(); const NEAR = "1200px"; @@ -50,7 +56,7 @@ const now = (next: string) => { dispose?.(); node.classList.remove("whitespace-pre-wrap"); - dispose = host.markdown?.(node, next); + dispose = host.markdown?.(node, next, { inert }); }; const later = (next: string) => { pending = next; diff --git a/ui/src/lib/panel/reasoning.svelte b/ui/src/lib/panel/reasoning.svelte new file mode 100644 index 0000000..ed0ec44 --- /dev/null +++ b/ui/src/lib/panel/reasoning.svelte @@ -0,0 +1,49 @@ + + +
+ + {#if expanded} +

+ {text.trim()} +

+ {/if} +
diff --git a/ui/src/lib/panel/say-bubble.svelte b/ui/src/lib/panel/say-bubble.svelte new file mode 100644 index 0000000..6634125 --- /dev/null +++ b/ui/src/lib/panel/say-bubble.svelte @@ -0,0 +1,11 @@ + + +
+ + +
diff --git a/ui/src/lib/panel/service-line.svelte b/ui/src/lib/panel/service-line.svelte new file mode 100644 index 0000000..5c68ff5 --- /dev/null +++ b/ui/src/lib/panel/service-line.svelte @@ -0,0 +1,28 @@ + + + diff --git a/ui/src/lib/panel/tool-node.svelte b/ui/src/lib/panel/tool-node.svelte index 7e46a9c..1414a18 100644 --- a/ui/src/lib/panel/tool-node.svelte +++ b/ui/src/lib/panel/tool-node.svelte @@ -33,6 +33,11 @@ running: "bg-signal animate-pulse-dot", }; const SHOW_DURATION_AFTER_MS = 2000; + const timed = $derived( + node.status === "running" + ? duration >= SHOW_DURATION_AFTER_MS + : node.endedAt !== null + );
0 && "ml-3 border-l pl-2")}> @@ -59,7 +64,7 @@ {summary} {/if} - {#if node.status !== "running" || duration >= SHOW_DURATION_AFTER_MS} + {#if timed} {fmtDuration(duration)} diff --git a/ui/src/lib/panel/turn-card.svelte b/ui/src/lib/panel/turn-card.svelte index 15042ef..9d0b4b0 100644 --- a/ui/src/lib/panel/turn-card.svelte +++ b/ui/src/lib/panel/turn-card.svelte @@ -1,5 +1,4 @@