From 9080eb7dee0ead64ea9b46ba4b910634855263a1 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 2 Sep 2026 05:16:13 +0200 Subject: [PATCH] perf(panel): cached info and history paint first, lazy markdown up the thread, background prefetch --- ui/src/lib/gateway.svelte.ts | 3 +- ui/src/lib/panel/chat-view.svelte | 21 +++++--- ui/src/lib/panel/conversation-view.svelte | 5 +- ui/src/lib/panel/conversation.svelte.ts | 19 ++++++- ui/src/lib/panel/history-cache.ts | 28 ++++++++++ ui/src/lib/panel/index.svelte.ts | 65 +++++++++++++++++++++++ ui/src/lib/panel/markdown.svelte | 52 ++++++++++++++++-- ui/src/lib/rail/rail.svelte | 7 +++ ui/src/lib/shell/board.svelte | 4 ++ ui/src/lib/shell/strip.svelte | 4 ++ 10 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 ui/src/lib/panel/history-cache.ts diff --git a/ui/src/lib/gateway.svelte.ts b/ui/src/lib/gateway.svelte.ts index 8c65902..8818d12 100644 --- a/ui/src/lib/gateway.svelte.ts +++ b/ui/src/lib/gateway.svelte.ts @@ -5,6 +5,7 @@ import type { LimitWindow, } from "./api/types"; import { panelCache } from "./panel/cache"; +import { browserHost } from "./panel/host"; import { ConversationIndex } from "./panel/index.svelte"; import { session } from "./session.svelte"; @@ -23,7 +24,7 @@ class Gateway extends ConversationIndex { jobs = $state(cache.get(JOBS_KEY) ?? null); constructor() { - super(() => session.client); + super(() => session.client, browserHost().cache ?? null); } async refreshJobs(): Promise { diff --git a/ui/src/lib/panel/chat-view.svelte b/ui/src/lib/panel/chat-view.svelte index 4ba3e71..4fee77d 100644 --- a/ui/src/lib/panel/chat-view.svelte +++ b/ui/src/lib/panel/chat-view.svelte @@ -8,6 +8,7 @@ import { cn } from "$lib/utils"; import type { ActivityModel } from "./activity.svelte"; import { summarizeInput, toolLabel } from "./activity.svelte"; + import { cacheableHistory, historyKey } from "./history-cache"; import { usePanelHost } from "./host"; import Markdown from "./markdown.svelte"; import QuestionCard from "./question-card.svelte"; @@ -26,7 +27,9 @@ } = $props(); const RESULT_CLIP = 600; - const CACHED_MESSAGES = 80; + // 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; const host = usePanelHost(); const NEAR_BOTTOM_PX = 120; const TICK_MS = 1000; @@ -59,7 +62,7 @@ failure = null; if (messages === null) { const cached = host.cache?.get( - `history:${conversationId}` + historyKey(conversationId) ); if (cached) { messages = cached; @@ -69,10 +72,7 @@ ({ messages } = await client.history(conversationId)); loadedAt = new Date().toISOString(); settle(); - host.cache?.set( - `history:${conversationId}`, - messages.slice(-CACHED_MESSAGES) - ); + host.cache?.set(historyKey(conversationId), cacheableHistory(messages)); } catch (cause) { failure = cause instanceof Error ? cause.message : String(cause); } @@ -181,6 +181,12 @@ 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 ?? ""}`; + } + function blocks(message: HistoryMessage): ContentBlock[] { return typeof message.content === "string" ? [{ text: message.content, type: "text" }] @@ -237,7 +243,7 @@ title="Empty conversation" /> {:else} - {#each messages as message, index (index)} + {#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)} @@ -303,6 +309,7 @@ ? "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} /> {:else if block.type === "tool_use"} diff --git a/ui/src/lib/panel/conversation-view.svelte b/ui/src/lib/panel/conversation-view.svelte index e294ce6..90f4b50 100644 --- a/ui/src/lib/panel/conversation-view.svelte +++ b/ui/src/lib/panel/conversation-view.svelte @@ -8,6 +8,7 @@ import ContextView from "./context-view.svelte"; import { ConversationFeed } from "./conversation.svelte"; import ConversationHeader from "./conversation-header.svelte"; + import { usePanelHost } from "./host"; import RawEntries from "./raw-entries.svelte"; let { @@ -28,9 +29,11 @@ initialView?: string; } = $props(); + const host = usePanelHost(); const feed = new ConversationFeed( () => client, - untrack(() => id) + untrack(() => id), + host.cache ?? null ); let view = $state(untrack(() => initialView)); let historyKey = $state(0); diff --git a/ui/src/lib/panel/conversation.svelte.ts b/ui/src/lib/panel/conversation.svelte.ts index 3f735fb..245d8a7 100644 --- a/ui/src/lib/panel/conversation.svelte.ts +++ b/ui/src/lib/panel/conversation.svelte.ts @@ -1,6 +1,9 @@ import type { ApiClient } from "$lib/api/client"; import { LiveStream } from "$lib/api/live.svelte"; +import type { ConversationInfo } from "$lib/api/types"; import { ActivityModel } from "./activity.svelte"; +import type { PanelCache } from "./cache"; +import { infoKey } from "./history-cache"; // One conversation, live: the snapshot from ``GET /api/conversations/{id}`` // (bindings, queue, the turn in flight) plus its SSE stream folded into an @@ -12,10 +15,20 @@ export class ConversationFeed { readonly id: string; readonly live: LiveStream; private readonly client: () => ApiClient | null; + private readonly cache: PanelCache | null; - constructor(client: () => ApiClient | null, id: string) { + constructor( + client: () => ApiClient | null, + id: string, + cache: PanelCache | null = null + ) { this.client = client; this.id = id; + this.cache = cache; + const known = cache?.get(infoKey(id)); + if (known) { + this.model.setConversation(known); + } this.live = new LiveStream(client, `/api/conversations/${id}/events`, { onEvent: (event) => this.model.apply(event), prepare: () => this.refresh(), @@ -28,7 +41,9 @@ export class ConversationFeed { return; } try { - this.model.setConversation(await client.conversation(this.id)); + const info = await client.conversation(this.id); + this.model.setConversation(info); + this.cache?.set(infoKey(this.id), info); this.error = null; } catch (error) { this.error = error instanceof Error ? error.message : String(error); diff --git a/ui/src/lib/panel/history-cache.ts b/ui/src/lib/panel/history-cache.ts new file mode 100644 index 0000000..c43ea2b --- /dev/null +++ b/ui/src/lib/panel/history-cache.ts @@ -0,0 +1,28 @@ +import type { ContentBlock, HistoryMessage } from "$lib/api/types"; + +const MAX_BYTES = 250_000; +const TAIL = 200; + +function slim(message: HistoryMessage): HistoryMessage { + if (typeof message.content === "string") { + return message; + } + const content: ContentBlock[] = message.content.map((block) => + block.type === "tool_result" ? { ...block, content: "" } : block + ); + return { ...message, content }; +} + +// The tail of a thread as it is worth keeping between opens: tool results +// dropped (hidden by default, and the bulk of the bytes), then trimmed from +// the head until it fits a slice of localStorage. +export function cacheableHistory(messages: HistoryMessage[]): HistoryMessage[] { + let tail = messages.slice(-TAIL).map(slim); + while (tail.length > 1 && JSON.stringify(tail).length > MAX_BYTES) { + tail = tail.slice(Math.ceil(tail.length / 4)); + } + return tail; +} + +export const historyKey = (id: string) => `history:${id}`; +export const infoKey = (id: string) => `info:${id}`; diff --git a/ui/src/lib/panel/index.svelte.ts b/ui/src/lib/panel/index.svelte.ts index c48d503..767a903 100644 --- a/ui/src/lib/panel/index.svelte.ts +++ b/ui/src/lib/panel/index.svelte.ts @@ -2,11 +2,16 @@ import type { ApiClient } from "$lib/api/client"; import { LiveStream } from "$lib/api/live.svelte"; import type { BusEvent, ConversationSummary } from "$lib/api/types"; import type { PanelCache } from "./cache"; +import { cacheableHistory, historyKey, infoKey } from "./history-cache"; const RELOAD_DEBOUNCE_MS = 1500; const PAGE = 500; const CACHED_ROWS = 200; const INDEX_KEY = "index"; +const WARM_TTL_MS = 60_000; +const WARM_GAP_MS = 120; +const WARM_AHEAD = 8; +const QUIET_KINDS = new Set(["job", "fork"]); // The conversation index kept fresh by ``/api/events``: rows land as // ``conversation.*`` events arrive, turn markers flip ``running_turn`` @@ -19,6 +24,8 @@ export class ConversationIndex { protected readonly client: () => ApiClient | null; private readonly cache: PanelCache | null; private reloadTimer: ReturnType | null = null; + private readonly warmed = new Map(); + private warming: Promise | null = null; constructor(client: () => ApiClient | null, cache: PanelCache | null = null) { this.client = client; @@ -55,6 +62,64 @@ export class ConversationIndex { this.conversations = list.conversations; this.loaded = true; this.cache?.set(INDEX_KEY, list.conversations.slice(0, CACHED_ROWS)); + this.prefetch(this.likely()); + } + + // What the operator opens next: anything alive, then the freshest strips. + private likely(): string[] { + const rows = this.conversations.filter( + (row) => !QUIET_KINDS.has(row.kind) && row.status === "open" + ); + const alive = rows.filter( + (row) => row.running_turn || row.pending_question + ); + const rest = rows + .filter((row) => !(row.running_turn || row.pending_question)) + .sort((a, b) => + (b.last_activity_at ?? b.created_at ?? "").localeCompare( + a.last_activity_at ?? a.created_at ?? "" + ) + ); + return [...alive, ...rest].slice(0, WARM_AHEAD).map((row) => row.id); + } + + // Warm the cache for these threads, one at a time, in the background. + prefetch(ids: string[]): void { + if (!this.cache) { + return; + } + const now = Date.now(); + const fresh = ids.filter( + (id) => now - (this.warmed.get(id) ?? 0) > WARM_TTL_MS + ); + if (fresh.length === 0) { + return; + } + for (const id of fresh) { + this.warmed.set(id, now); + } + const run = async () => { + for (const id of fresh) { + const client = this.client(); + if (!client) { + return; + } + try { + // biome-ignore lint/performance/noAwaitInLoops: one thread at a time, on purpose - background warmth, not a race + const [history, info] = await Promise.all([ + client.history(id), + client.conversation(id), + ]); + this.cache?.set(historyKey(id), cacheableHistory(history.messages)); + this.cache?.set(infoKey(id), info); + } catch { + this.warmed.delete(id); + } + // biome-ignore lint/performance/noAwaitInLoops: the pause between fetches is the point + await new Promise((resolve) => setTimeout(resolve, WARM_GAP_MS)); + } + }; + this.warming = (this.warming ?? Promise.resolve()).then(run, run); } apply(event: BusEvent): void { diff --git a/ui/src/lib/panel/markdown.svelte b/ui/src/lib/panel/markdown.svelte index 565ecd7..d194e99 100644 --- a/ui/src/lib/panel/markdown.svelte +++ b/ui/src/lib/panel/markdown.svelte @@ -3,8 +3,13 @@ import { usePanelHost } from "./host"; import { linkOf, renderMarkdown } from "./markdown"; - let { text, class: className = "" }: { text: string; class?: string } = - $props(); + let { + text, + class: className = "", + eager = true, + }: { text: string; class?: string; eager?: boolean } = $props(); + + const NEAR = "1200px"; const host = usePanelHost(); const html = $derived(host.markdown ? "" : renderMarkdown(text)); @@ -36,15 +41,52 @@ }; } + // The host renders when asked; far up the thread that is when the + // node comes near the viewport, with the plain text holding its place. function render(node: HTMLElement, value: string) { - let dispose = host.markdown?.(node, value); + let dispose: (() => void) | undefined; + let watcher: IntersectionObserver | null = null; + let pending = value; + const now = (next: string) => { + dispose?.(); + node.classList.remove("whitespace-pre-wrap"); + dispose = host.markdown?.(node, next); + }; + const later = (next: string) => { + pending = next; + node.textContent = next; + node.classList.add("whitespace-pre-wrap"); + if (watcher) { + return; + } + watcher = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + watcher?.disconnect(); + watcher = null; + now(pending); + } + }, + { rootMargin: NEAR } + ); + watcher.observe(node); + }; + if (eager || typeof IntersectionObserver === "undefined") { + now(value); + } else { + later(value); + } return { destroy() { + watcher?.disconnect(); dispose?.(); }, update(next: string) { - dispose?.(); - dispose = host.markdown?.(node, next); + if (watcher) { + later(next); + } else { + now(next); + } }, }; } diff --git a/ui/src/lib/rail/rail.svelte b/ui/src/lib/rail/rail.svelte index 16a4ae2..b32ea54 100644 --- a/ui/src/lib/rail/rail.svelte +++ b/ui/src/lib/rail/rail.svelte @@ -132,6 +132,7 @@ } const refresh = () => index.load().catch(() => undefined); + const warm = (id: string) => index.prefetch([id]); // What the agent did on its own that day: digests and scheduled runs. function quietSummary(group: DayGroup): string { @@ -208,6 +209,7 @@ {now} onChanged={refresh} onclick={onOpen} + onWarm={warm} {row} /> {/each} @@ -283,6 +285,7 @@ {now} onChanged={refresh} onclick={onOpen} + onWarm={warm} row={group.master} /> {/if} @@ -295,6 +298,7 @@ {now} onChanged={refresh} onclick={onOpen} + onWarm={warm} {row} /> {/each} @@ -307,6 +311,7 @@ {now} onChanged={refresh} onclick={onOpen} + onWarm={warm} {row} /> {/each} @@ -319,6 +324,7 @@ {now} onChanged={refresh} onclick={onOpen} + onWarm={warm} {row} /> {/each} @@ -347,6 +353,7 @@ {now} onChanged={refresh} onclick={onOpen} + onWarm={warm} {row} /> {/each} diff --git a/ui/src/lib/shell/board.svelte b/ui/src/lib/shell/board.svelte index 1a17939..2e7f089 100644 --- a/ui/src/lib/shell/board.svelte +++ b/ui/src/lib/shell/board.svelte @@ -79,6 +79,7 @@ const href = (id: string) => `${base}/conversations/${id}`; const GRAPH_FILES = 24; const refresh = () => gateway.load().catch(() => undefined); + const warm = (id: string) => gateway.prefetch([id]); async function loadVault(masterId: string) { const { client } = session; @@ -414,6 +415,7 @@ {now} onChanged={refresh} onclick={open} + onWarm={warm} open={Boolean(info?.question)} {row} state="waiting" @@ -449,6 +451,7 @@ {now} onChanged={refresh} onclick={open} + onWarm={warm} {row} state="running" /> @@ -532,6 +535,7 @@ {now} onChanged={refresh} onclick={open} + onWarm={warm} {row} state="quiet" /> diff --git a/ui/src/lib/shell/strip.svelte b/ui/src/lib/shell/strip.svelte index e5f735c..741876e 100644 --- a/ui/src/lib/shell/strip.svelte +++ b/ui/src/lib/shell/strip.svelte @@ -21,6 +21,7 @@ onclick, client = null, onChanged, + onWarm, }: { row: ConversationSummary; href?: string; @@ -36,6 +37,8 @@ // press) with the conversation's actions. client?: ApiClient | null; onChanged?: () => void; + // The pointer is over the strip: a good moment to fetch what it opens. + onWarm?: (id: string) => void; } = $props(); const TITLE_MAX = 72; @@ -82,6 +85,7 @@ data-state={state} href={href ?? undefined} onclick={activate} + onpointerenter={onWarm ? () => onWarm(row.id) : undefined} role={href ? undefined : "button"} tabindex="0" this={tag}