feat(ui): finished turns keep their tool tree, reasoning and say bubbles, the thread stops dragging the reader down
This commit is contained in:
1 parent
0fec15fb3b
commit
b0e5aaf599
10 files changed
+414
-184
No files matched your search
@@ -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<string, unknown>;
|
||||
if (sdk.type === "content_block_start") {
|
||||
const block = sdk.content_block 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;
|
||||
this.onBlockStart(turn, sdk.content_block, parent, ts);
|
||||
} else if (sdk.type === "content_block_delta" && parent === null) {
|
||||
this.onDelta(turn, sdk.delta as Record<string, unknown> | undefined);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (sdk.type === "content_block_delta" && parent === null) {
|
||||
const delta = sdk.delta as Record<string, unknown> | 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: [],
|
||||
|
||||
+242
-158
@@ -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<string, ToolNode> = {};
|
||||
|
||||
let messages = $state<HistoryMessage[] | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let showResults = $state(false);
|
||||
let loadedAt = $state(new Date(0).toISOString());
|
||||
let scroller = $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 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]+\]|<task-notification>)/;
|
||||
const SECONDS = /:\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) {
|
||||
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<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>
|
||||
|
||||
<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"
|
||||
onscroll={() => {
|
||||
if (Date.now() > settleUntil) {
|
||||
pinned = nearBottom();
|
||||
}
|
||||
}}
|
||||
onscroll={trackReader}
|
||||
bind:this={scroller}
|
||||
>
|
||||
<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}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{: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}
|
||||
<p class="label-quiet self-center pt-2">
|
||||
{fmtDateTime(message.ts).replace(DAY_TIME, "")}
|
||||
</p>
|
||||
{/if}
|
||||
{#if system}
|
||||
{#each rows as row (`${row.kind}:${row.key}`)}
|
||||
{#if row.kind === "day"}
|
||||
<p class="label-quiet self-center pt-2">{row.day}</p>
|
||||
{:else if row.kind === "system"}
|
||||
<div class="flex min-w-0 items-start justify-center gap-2">
|
||||
{#if message.ts}
|
||||
{#if row.ts}
|
||||
<span
|
||||
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>
|
||||
{/if}
|
||||
<button
|
||||
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>
|
||||
<ServiceLine text={row.text} />
|
||||
</div>
|
||||
{:else if parts.length > 0 && !(isResultOnly && !showResults)}
|
||||
<div
|
||||
class={cn(
|
||||
"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}
|
||||
{:else if row.kind === "user"}
|
||||
<div class="group flex min-w-0 max-w-full flex-col items-end gap-1.5">
|
||||
{#if row.ts}
|
||||
<span
|
||||
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>
|
||||
{/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}
|
||||
<Markdown
|
||||
class={cn(
|
||||
"min-w-0 @md:max-w-[75ch] max-w-full",
|
||||
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}
|
||||
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"}
|
||||
<p
|
||||
class="flex min-w-0 max-w-full items-baseline gap-2 px-1 text-xs"
|
||||
>
|
||||
<span class="shrink-0 font-medium"
|
||||
>{toolLabel(block.name ?? "?")}</span
|
||||
>
|
||||
<span class="min-w-0 truncate text-muted-foreground">
|
||||
{clip(summarizeInput(block.name ?? "", block.input), 160)}
|
||||
</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>
|
||||
{@const spoken = said(block)}
|
||||
{#if spoken}
|
||||
<div class="py-1"><SayBubble text={spoken} /></div>
|
||||
{:else}
|
||||
<ToolNodeView node={nodeOf(block)} nodes={NO_NODES} now={0} />
|
||||
{/if}
|
||||
{:else if block.type === "thinking" && block.thinking?.trim()}
|
||||
<Reasoning text={block.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}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
);
|
||||
</script>
|
||||
|
||||
<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>
|
||||
{/if}
|
||||
</span>
|
||||
{#if node.status !== "running" || duration >= SHOW_DURATION_AFTER_MS}
|
||||
{#if timed}
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{fmtDuration(duration)}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
|
||||
import StatusPill from "$lib/components/status-pill.svelte";
|
||||
import {
|
||||
elapsedMs,
|
||||
@@ -11,6 +10,8 @@
|
||||
import { cn } from "$lib/utils";
|
||||
import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte";
|
||||
import Markdown from "./markdown.svelte";
|
||||
import Reasoning from "./reasoning.svelte";
|
||||
import SayBubble from "./say-bubble.svelte";
|
||||
import ToolNodeView from "./tool-node.svelte";
|
||||
|
||||
let { turn, now }: { turn: Turn; now: number } = $props();
|
||||
@@ -59,7 +60,7 @@
|
||||
tool {toolCount === 1 ? "call" : "calls"}
|
||||
</span>
|
||||
{/if}
|
||||
{#if turn.thinking > 0 && turn.status === "running"}
|
||||
{#if turn.thinking > 0 && turn.status === "running" && !turn.reasoning}
|
||||
<span class="text-kind-deep">thinking…</span>
|
||||
{/if}
|
||||
{#if turn.usage}
|
||||
@@ -79,9 +80,13 @@
|
||||
{:else if turn.userText}
|
||||
<Markdown
|
||||
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
|
||||
inert
|
||||
text={turn.userText}
|
||||
/>
|
||||
{/if}
|
||||
{#if turn.reasoning.trim()}
|
||||
<Reasoning live={turn.status === "running"} text={turn.reasoning} />
|
||||
{/if}
|
||||
{#if turn.roots.length > 0}
|
||||
<div class="flex flex-col">
|
||||
{#each turn.roots as rootId (rootId)}
|
||||
@@ -92,10 +97,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#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">
|
||||
<MessageSquareIcon class="mt-1 size-3.5 shrink-0 text-link" />
|
||||
<Markdown class="min-w-0 flex-1" {text} />
|
||||
</div>
|
||||
<SayBubble {text} />
|
||||
{/each}
|
||||
{#if turn.text}
|
||||
<Markdown class="mx-1 px-1" text={turn.text} />
|
||||
|
||||
Reference in new issue
Block a user