feat(ui): finished turns keep their tool tree, reasoning and say bubbles, the thread stops dragging the reader down

This commit is contained in:
hh committed 2026-10-13 01:22:44 +02:00
1 parent 0fec15fb3b
commit b0e5aaf599
10 files changed
+414 -184

No files matched your search

+39 -16
View File
@@ -30,6 +30,8 @@ export interface Turn {
// A tool call came between two pieces of text; the next piece starts a // A tool call came between two pieces of text; the next piece starts a
// paragraph, as the final message will show it. // paragraph, as the final message will show it.
paused: boolean; paused: boolean;
// What the model thought, when its thinking is reasoning and not progress.
reasoning: string;
resultSubtype: string | null; resultSubtype: string | null;
roots: string[]; roots: string[];
says: string[]; says: string[];
@@ -223,25 +225,45 @@ export class ActivityModel {
const turn = this.turnFor(turnId, ts, parent); const turn = this.turnFor(turnId, ts, parent);
const sdk = raw as Record<string, unknown>; const sdk = raw as Record<string, unknown>;
if (sdk.type === "content_block_start") { if (sdk.type === "content_block_start") {
const block = sdk.content_block as Record<string, unknown> | undefined; this.onBlockStart(turn, sdk.content_block, parent, ts);
if (block?.type === "tool_use" && typeof block.id === "string") { } else if (sdk.type === "content_block_delta" && parent === null) {
this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts); this.onDelta(turn, sdk.delta as Record<string, unknown> | undefined);
} else if (block?.type === "thinking" && parent === null) { }
turn.thinking += 1; }
private onBlockStart(
turn: Turn,
raw: unknown,
parent: string | null,
ts: string
): void {
const block = raw as Record<string, unknown> | 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<string, unknown> | 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; return;
} }
if (sdk.type === "content_block_delta" && parent === null) { const text = thought ?? delta?.text;
const delta = sdk.delta as Record<string, unknown> | undefined; if (typeof text === "string" && text) {
const progress = this.conversation?.thinking_is_progress === true; turn.text = joinText(turn.text, text, turn.paused);
const text = turn.paused = false;
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;
}
} }
} }
@@ -365,6 +387,7 @@ export class ActivityModel {
nodes: {}, nodes: {},
origin: origin ?? "?", origin: origin ?? "?",
paused: false, paused: false,
reasoning: "",
resultSubtype: null, resultSubtype: null,
roots: [], roots: [],
says: [], says: [],
+242 -158
View File
@@ -6,12 +6,16 @@
import ErrorNote from "$lib/components/error-note.svelte"; import ErrorNote from "$lib/components/error-note.svelte";
import { clip, fmtDateTime, fmtTime } from "$lib/format"; import { clip, fmtDateTime, fmtTime } from "$lib/format";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte"; import type { ActivityModel, ToolNode } from "./activity.svelte";
import { isLive, summarizeInput, toolLabel } from "./activity.svelte"; import { isLive } from "./activity.svelte";
import { cacheableHistory, historyKey } from "./history-cache"; import { cacheableHistory, historyKey } from "./history-cache";
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import Markdown from "./markdown.svelte"; import Markdown from "./markdown.svelte";
import QuestionCard from "./question-card.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"; import TurnCard from "./turn-card.svelte";
let { let {
@@ -26,25 +30,42 @@
refreshKey?: number; refreshKey?: number;
} = $props(); } = $props();
const RESULT_CLIP = 600; interface Step {
// Markdown for the last few screens renders at once; the rest waits block: ContentBlock;
// until scrolled near, so a long thread paints as fast as a short one. eager: boolean;
const EAGER_MARKDOWN = 40; 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 host = usePanelHost();
const NEAR_BOTTOM_PX = 120; const AT_BOTTOM_PX = 8;
const TICK_MS = 1000; const TICK_MS = 1000;
const NO_NODES: Record<string, ToolNode> = {};
let messages = $state<HistoryMessage[] | null>(null); let messages = $state<HistoryMessage[] | null>(null);
let failure = $state<string | null>(null); let failure = $state<string | null>(null);
let showResults = $state(false);
let loadedAt = $state(new Date(0).toISOString()); let loadedAt = $state(new Date(0).toISOString());
let scroller = $state<HTMLDivElement | null>(null); let scroller = $state<HTMLDivElement | null>(null);
let body = $state<HTMLDivElement | null>(null); let body = $state<HTMLDivElement | null>(null);
const SETTLE_MS = 2500;
const SETTLE_STEP_MS = 120;
let settleUntil = 0;
let now = $state(Date.now()); let now = $state(Date.now());
let pinned = true; let pinned = true;
let readerRow: { node: Element; top: number } | null = null;
let ownScroll = false;
const tail = $derived( const tail = $derived(
model.turns model.turns
@@ -53,7 +74,11 @@
); );
const tailSize = $derived( const tailSize = $derived(
tail.reduce( 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 0
) )
); );
@@ -71,46 +96,61 @@
try { try {
({ messages } = await client.history(conversationId)); ({ messages } = await client.history(conversationId));
loadedAt = new Date().toISOString(); loadedAt = new Date().toISOString();
settle();
host.cache?.set(historyKey(conversationId), cacheableHistory(messages)); host.cache?.set(historyKey(conversationId), cacheableHistory(messages));
} catch (cause) { } catch (cause) {
failure = cause instanceof Error ? cause.message : String(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() { function toBottom() {
if (scroller) { if (scroller) {
scroller.scrollTop = 0; scroller.scrollTop = 0;
} }
} }
async function scrollToBottom() { function topRow(): { node: Element; top: number } | null {
await tick(); if (!(scroller && body)) {
toBottom(); return null;
} }
const edge = scroller.getBoundingClientRect().top;
// A fresh thread keeps growing after it is on screen: markdown renders const rows = body.children;
// late, embeds load, fonts land. Hold the bottom for a while after a load. let low = 0;
function settle() { let high = rows.length - 1;
settleUntil = Date.now() + SETTLE_MS; while (low < high) {
const step = () => { const middle = Math.floor((low + high) / 2);
if (!pinned || Date.now() > settleUntil) { if (rows[middle].getBoundingClientRect().bottom > edge) {
return; 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(); toBottom();
setTimeout(step, SETTLE_STEP_MS); return;
}; }
step(); 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 { function dayOf(iso: string | null | undefined): string {
return iso ? iso.slice(0, 10) : ""; return iso ? iso.slice(0, 10) : "";
} }
@@ -121,7 +161,6 @@
$effect(() => { $effect(() => {
if (refreshKey >= 0) { if (refreshKey >= 0) {
pinned = true;
untrack(() => load()); untrack(() => load());
} }
}); });
@@ -129,21 +168,15 @@
$effect(() => { $effect(() => {
const count = (messages?.length ?? 0) + tailSize; const count = (messages?.length ?? 0) + tailSize;
if (pinned && count >= 0) { 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(() => { $effect(() => {
if (!(scroller && body)) { if (!(scroller && body)) {
return; return;
} }
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(keepReaderRowInPlace);
if (pinned && scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
});
observer.observe(body); observer.observe(body);
return () => observer.disconnect(); return () => observer.disconnect();
}); });
@@ -157,36 +190,13 @@
return () => clearInterval(timer); 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]+\]|<task-notification>)/; const SYSTEM_HEAD = /^(\[[^\]\n]+\]|<task-notification>)/;
const SECONDS = /:\d{2}$/; const SECONDS = /:\d{2}$/;
const DAY_TIME = /,?\s*\d{2}:\d{2}$/; const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
let openSystem = $state<Set<number>>(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) { 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[] { function blocks(message: HistoryMessage): ContentBlock[] {
@@ -222,28 +232,127 @@
} }
return ""; return "";
} }
const results = $derived.by(() => {
const found = new Map<string, { failed: boolean; text: string }>();
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;
});
</script> </script>
<div <div
class="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto overflow-x-hidden px-3 py-3 [overflow-anchor:none] @md:px-6" class="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto overflow-x-hidden px-3 py-3 [overflow-anchor:none] @md:px-6"
onscroll={() => { onscroll={trackReader}
if (Date.now() > settleUntil) {
pinned = nearBottom();
}
}}
bind:this={scroller} bind:this={scroller}
> >
<div class="flex min-w-0 max-w-full flex-col gap-3" bind:this={body}> <div class="flex min-w-0 max-w-full flex-col gap-3" bind:this={body}>
<button
class="rule-word self-end text-xs"
data-active={showResults}
onclick={() => {
showResults = !showResults;
}}
type="button"
>
{showResults ? "hide tool results" : "show tool results"}
</button>
{#if failure} {#if failure}
<ErrorNote message={failure} retry={load} /> <ErrorNote message={failure} retry={load} />
{:else if messages === null} {:else if messages === null}
@@ -256,97 +365,72 @@
title="Empty conversation" title="Empty conversation"
/> />
{:else} {:else}
{#each messages as message, index (keyOf(message, index, messages.length))} {#each rows as row (`${row.kind}:${row.key}`)}
{@const parts = blocks(message)} {#if row.kind === "day"}
{@const isResultOnly = parts.every((b) => b.type === "tool_result")} <p class="label-quiet self-center pt-2">{row.day}</p>
{@const system = systemText(message)} {:else if row.kind === "system"}
{@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}
<p class="label-quiet self-center pt-2">
{fmtDateTime(message.ts).replace(DAY_TIME, "")}
</p>
{/if}
{#if system}
<div class="flex min-w-0 items-start justify-center gap-2"> <div class="flex min-w-0 items-start justify-center gap-2">
{#if message.ts} {#if row.ts}
<span <span
class="tabular shrink-0 py-1 text-[11px] text-muted-foreground opacity-60" class="tabular shrink-0 py-1 text-[11px] text-muted-foreground opacity-60"
title={fmtDateTime(message.ts)} title={fmtDateTime(row.ts)}
> >
{minuteOf(message.ts)} {minuteOf(row.ts)}
</span> </span>
{/if} {/if}
<button <ServiceLine text={row.text} />
aria-expanded={openSystem.has(index)}
class="@md:max-w-[75ch] min-w-0 max-w-full rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
onclick={() => toggleSystem(index)}
type="button"
>
{#if openSystem.has(index)}
<span class="whitespace-pre-wrap break-words">{system}</span>
{:else}
<span class="block truncate"
>{clip(system.split("\n")[0], 120)}</span
>
{/if}
</button>
</div> </div>
{:else if parts.length > 0 && !(isResultOnly && !showResults)} {:else if row.kind === "user"}
<div <div class="group flex min-w-0 max-w-full flex-col items-end gap-1.5">
class={cn( {#if row.ts}
"group flex min-w-0 max-w-full flex-col gap-1.5",
message.role === "user" && !isResultOnly && "items-end"
)}
>
{#if message.ts && (newMinute || spoken) && !isResultOnly}
<span <span
class="tabular px-1 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100" class="tabular px-1 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100"
title={fmtDateTime(message.ts)} title={fmtDateTime(row.ts)}
> >
{minuteOf(message.ts)} {minuteOf(row.ts)}
</span> </span>
{/if} {/if}
{#each parts as block, blockIndex (blockIndex)} {#each row.texts as text, index (index)}
<Markdown
class="min-w-0 @md:max-w-[75ch] max-w-full rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
eager={row.eager}
inert
{text}
/>
{/each}
{#if row.service}
<ServiceLine text={row.service} />
{/if}
</div>
{:else}
<div class="group flex min-w-0 max-w-full flex-col">
{#each row.steps as step (step.key)}
{@const block = step.block}
{#if step.stampedAt}
<span
class="tabular px-1 pt-1.5 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100"
title={fmtDateTime(step.stampedAt)}
>
{minuteOf(step.stampedAt)}
</span>
{/if}
{#if block.type === "text" && block.text} {#if block.type === "text" && block.text}
<Markdown <Markdown
class={cn( class="min-w-0 @md:max-w-[75ch] max-w-full px-1 py-1"
"min-w-0 @md:max-w-[75ch] max-w-full", eager={step.eager}
message.role === "user"
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
: "px-1 py-1"
)}
eager={index >= messages.length - EAGER_MARKDOWN}
text={block.text} text={block.text}
/> />
{:else if block.type === "tool_use"} {:else if block.type === "tool_use"}
<p {@const spoken = said(block)}
class="flex min-w-0 max-w-full items-baseline gap-2 px-1 text-xs" {#if spoken}
> <div class="py-1"><SayBubble text={spoken} /></div>
<span class="shrink-0 font-medium" {:else}
>{toolLabel(block.name ?? "?")}</span <ToolNodeView node={nodeOf(block)} nodes={NO_NODES} now={0} />
> {/if}
<span class="min-w-0 truncate text-muted-foreground"> {:else if block.type === "thinking" && block.thinking?.trim()}
{clip(summarizeInput(block.name ?? "", block.input), 160)} <Reasoning text={block.thinking} />
</span>
</p>
{:else if block.type === "tool_result" && showResults}
<pre
class={cn(
"max-h-48 max-w-full overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
block.is_error
? "bg-destructive/8 text-destructive"
: "bg-muted/40"
)}
>{clip(resultText(block), RESULT_CLIP)}</pre>
{:else if block.type === "thinking"} {:else if block.type === "thinking"}
<p class="px-1 text-kind-deep text-xs">thinking</p> <p class="px-1.5 py-1 text-kind-deep text-xs">thinking</p>
{/if} {/if}
{/each} {/each}
</div> </div>
+18
View File
@@ -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)
);
}
+5 -1
View File
@@ -11,7 +11,11 @@ export interface PanelHost {
// Where the index and the last threads persist between opens. // Where the index and the last threads persist between opens.
cache?: PanelCache; cache?: PanelCache;
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify. // 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"; name: "browser" | "obsidian";
openLink: (target: string, kind: LinkKind) => void; openLink: (target: string, kind: LinkKind) => void;
// Opens the note a conversation lives in (its markdown binding). // Opens the note a conversation lives in (its markdown binding).
+8 -2
View File
@@ -7,7 +7,13 @@
text, text,
class: className = "", class: className = "",
eager = true, eager = true,
}: { text: string; class?: string; eager?: boolean } = $props(); inert = false,
}: {
text: string;
class?: string;
eager?: boolean;
inert?: boolean;
} = $props();
const NEAR = "1200px"; const NEAR = "1200px";
@@ -50,7 +56,7 @@
const now = (next: string) => { const now = (next: string) => {
dispose?.(); dispose?.();
node.classList.remove("whitespace-pre-wrap"); node.classList.remove("whitespace-pre-wrap");
dispose = host.markdown?.(node, next); dispose = host.markdown?.(node, next, { inert });
}; };
const later = (next: string) => { const later = (next: string) => {
pending = next; pending = next;
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils";
let { text, live = false }: { text: string; live?: boolean } = $props();
const PREVIEW = 400;
const SPACES = /\s+/g;
let expanded = $state(false);
const flat = $derived(text.replace(SPACES, " ").trim());
</script>
<div class="flex min-w-0 max-w-full flex-col">
<button
aria-expanded={expanded}
class="row-hover ledger-grid grid-cols-[auto_auto_minmax(0,1fr)] rounded-md px-1.5 py-1 text-left text-sm"
onclick={() => {
expanded = !expanded;
}}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
expanded && "rotate-90"
)}
/>
<span class="size-2 rounded-full bg-kind-deep"></span>
<span class="flex min-w-0 items-baseline gap-2">
<span class="shrink-0 font-medium text-kind-deep">reasoning</span>
{#if live && !expanded}
<span class="truncate text-left text-muted-foreground" dir="rtl">
<bdi dir="ltr">{flat.slice(-PREVIEW)}</bdi>
</span>
{:else if !expanded}
<span class="truncate text-muted-foreground"
>{flat.slice(0, PREVIEW)}</span
>
{/if}
</span>
</button>
{#if expanded}
<p
class="mb-1 ml-7 @md:max-w-[75ch] whitespace-pre-wrap break-words text-muted-foreground text-sm"
>
{text.trim()}
</p>
{/if}
</div>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
import Markdown from "./markdown.svelte";
let { text }: { text: string } = $props();
</script>
<div class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5">
<MessageSquareIcon class="mt-1 size-3.5 shrink-0 text-link" />
<Markdown class="min-w-0 flex-1" {text} />
</div>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import { clip } from "$lib/format";
import { cn } from "$lib/utils";
let { text, class: className = "" }: { text: string; class?: string } =
$props();
const HEAD_CLIP = 120;
let expanded = $state(false);
</script>
<button
aria-expanded={expanded}
class={cn(
"min-w-0 @md:max-w-[75ch] max-w-full rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent",
className
)}
onclick={() => {
expanded = !expanded;
}}
type="button"
>
{#if expanded}
<span class="whitespace-pre-wrap break-words">{text}</span>
{:else}
<span class="block truncate">{clip(text.split("\n")[0], HEAD_CLIP)}</span>
{/if}
</button>
+6 -1
View File
@@ -33,6 +33,11 @@
running: "bg-signal animate-pulse-dot", running: "bg-signal animate-pulse-dot",
}; };
const SHOW_DURATION_AFTER_MS = 2000; const SHOW_DURATION_AFTER_MS = 2000;
const timed = $derived(
node.status === "running"
? duration >= SHOW_DURATION_AFTER_MS
: node.endedAt !== null
);
</script> </script>
<div class={cn("flex flex-col", depth > 0 && "ml-3 border-l pl-2")}> <div class={cn("flex flex-col", depth > 0 && "ml-3 border-l pl-2")}>
@@ -59,7 +64,7 @@
<span class="truncate text-muted-foreground">{summary}</span> <span class="truncate text-muted-foreground">{summary}</span>
{/if} {/if}
</span> </span>
{#if node.status !== "running" || duration >= SHOW_DURATION_AFTER_MS} {#if timed}
<span class="tabular text-muted-foreground text-xs"> <span class="tabular text-muted-foreground text-xs">
{fmtDuration(duration)} {fmtDuration(duration)}
</span> </span>
+8 -6
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
import StatusPill from "$lib/components/status-pill.svelte"; import StatusPill from "$lib/components/status-pill.svelte";
import { import {
elapsedMs, elapsedMs,
@@ -11,6 +10,8 @@
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte"; import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte";
import Markdown from "./markdown.svelte"; import Markdown from "./markdown.svelte";
import Reasoning from "./reasoning.svelte";
import SayBubble from "./say-bubble.svelte";
import ToolNodeView from "./tool-node.svelte"; import ToolNodeView from "./tool-node.svelte";
let { turn, now }: { turn: Turn; now: number } = $props(); let { turn, now }: { turn: Turn; now: number } = $props();
@@ -59,7 +60,7 @@
tool {toolCount === 1 ? "call" : "calls"} tool {toolCount === 1 ? "call" : "calls"}
</span> </span>
{/if} {/if}
{#if turn.thinking > 0 && turn.status === "running"} {#if turn.thinking > 0 && turn.status === "running" && !turn.reasoning}
<span class="text-kind-deep">thinking…</span> <span class="text-kind-deep">thinking…</span>
{/if} {/if}
{#if turn.usage} {#if turn.usage}
@@ -79,9 +80,13 @@
{:else if turn.userText} {:else if turn.userText}
<Markdown <Markdown
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5" class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
inert
text={turn.userText} text={turn.userText}
/> />
{/if} {/if}
{#if turn.reasoning.trim()}
<Reasoning live={turn.status === "running"} text={turn.reasoning} />
{/if}
{#if turn.roots.length > 0} {#if turn.roots.length > 0}
<div class="flex flex-col"> <div class="flex flex-col">
{#each turn.roots as rootId (rootId)} {#each turn.roots as rootId (rootId)}
@@ -92,10 +97,7 @@
</div> </div>
{/if} {/if}
{#each turn.says as text, index (index)} {#each turn.says as text, index (index)}
<div class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5"> <SayBubble {text} />
<MessageSquareIcon class="mt-1 size-3.5 shrink-0 text-link" />
<Markdown class="min-w-0 flex-1" {text} />
</div>
{/each} {/each}
{#if turn.text} {#if turn.text}
<Markdown class="mx-1 px-1" text={turn.text} /> <Markdown class="mx-1 px-1" text={turn.text} />