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
|
// 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
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
// 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).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in new issue
Block a user