perf(panel): cached info and history paint first, lazy markdown up the thread, background prefetch

This commit is contained in:
hh
2026-09-02 05:16:13 +02:00
parent 2d903c7c5f
commit 5b851c11d4
10 changed files with 192 additions and 16 deletions
+2 -1
View File
@@ -5,6 +5,7 @@ import type {
LimitWindow, LimitWindow,
} from "./api/types"; } from "./api/types";
import { panelCache } from "./panel/cache"; import { panelCache } from "./panel/cache";
import { browserHost } from "./panel/host";
import { ConversationIndex } from "./panel/index.svelte"; import { ConversationIndex } from "./panel/index.svelte";
import { session } from "./session.svelte"; import { session } from "./session.svelte";
@@ -23,7 +24,7 @@ class Gateway extends ConversationIndex {
jobs = $state<JobsResponse | null>(cache.get<JobsResponse>(JOBS_KEY) ?? null); jobs = $state<JobsResponse | null>(cache.get<JobsResponse>(JOBS_KEY) ?? null);
constructor() { constructor() {
super(() => session.client); super(() => session.client, browserHost().cache ?? null);
} }
async refreshJobs(): Promise<void> { async refreshJobs(): Promise<void> {
+14 -7
View File
@@ -8,6 +8,7 @@
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte"; import type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte"; import { summarizeInput, toolLabel } from "./activity.svelte";
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";
@@ -26,7 +27,9 @@
} = $props(); } = $props();
const RESULT_CLIP = 600; 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 host = usePanelHost();
const NEAR_BOTTOM_PX = 120; const NEAR_BOTTOM_PX = 120;
const TICK_MS = 1000; const TICK_MS = 1000;
@@ -59,7 +62,7 @@
failure = null; failure = null;
if (messages === null) { if (messages === null) {
const cached = host.cache?.get<HistoryMessage[]>( const cached = host.cache?.get<HistoryMessage[]>(
`history:${conversationId}` historyKey(conversationId)
); );
if (cached) { if (cached) {
messages = cached; messages = cached;
@@ -69,10 +72,7 @@
({ messages } = await client.history(conversationId)); ({ messages } = await client.history(conversationId));
loadedAt = new Date().toISOString(); loadedAt = new Date().toISOString();
settle(); settle();
host.cache?.set( host.cache?.set(historyKey(conversationId), cacheableHistory(messages));
`history:${conversationId}`,
messages.slice(-CACHED_MESSAGES)
);
} catch (cause) { } catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause); failure = cause instanceof Error ? cause.message : String(cause);
} }
@@ -181,6 +181,12 @@
openSystem = next; 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[] { function blocks(message: HistoryMessage): ContentBlock[] {
return typeof message.content === "string" return typeof message.content === "string"
? [{ text: message.content, type: "text" }] ? [{ text: message.content, type: "text" }]
@@ -237,7 +243,7 @@
title="Empty conversation" title="Empty conversation"
/> />
{:else} {:else}
{#each messages as message, index (index)} {#each messages as message, index (keyOf(message, index, messages.length))}
{@const parts = blocks(message)} {@const parts = blocks(message)}
{@const isResultOnly = parts.every((b) => b.type === "tool_result")} {@const isResultOnly = parts.every((b) => b.type === "tool_result")}
{@const system = systemText(message)} {@const system = systemText(message)}
@@ -303,6 +309,7 @@
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2" ? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
: "px-1 py-1" : "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"}
+4 -1
View File
@@ -8,6 +8,7 @@
import ContextView from "./context-view.svelte"; import ContextView from "./context-view.svelte";
import { ConversationFeed } from "./conversation.svelte"; import { ConversationFeed } from "./conversation.svelte";
import ConversationHeader from "./conversation-header.svelte"; import ConversationHeader from "./conversation-header.svelte";
import { usePanelHost } from "./host";
import RawEntries from "./raw-entries.svelte"; import RawEntries from "./raw-entries.svelte";
let { let {
@@ -28,9 +29,11 @@
initialView?: string; initialView?: string;
} = $props(); } = $props();
const host = usePanelHost();
const feed = new ConversationFeed( const feed = new ConversationFeed(
() => client, () => client,
untrack(() => id) untrack(() => id),
host.cache ?? null
); );
let view = $state(untrack(() => initialView)); let view = $state(untrack(() => initialView));
let historyKey = $state(0); let historyKey = $state(0);
+17 -2
View File
@@ -1,6 +1,9 @@
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
import { LiveStream } from "$lib/api/live.svelte"; import { LiveStream } from "$lib/api/live.svelte";
import type { ConversationInfo } from "$lib/api/types";
import { ActivityModel } from "./activity.svelte"; 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}`` // One conversation, live: the snapshot from ``GET /api/conversations/{id}``
// (bindings, queue, the turn in flight) plus its SSE stream folded into an // (bindings, queue, the turn in flight) plus its SSE stream folded into an
@@ -12,10 +15,20 @@ export class ConversationFeed {
readonly id: string; readonly id: string;
readonly live: LiveStream; readonly live: LiveStream;
private readonly client: () => ApiClient | null; 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.client = client;
this.id = id; this.id = id;
this.cache = cache;
const known = cache?.get<ConversationInfo>(infoKey(id));
if (known) {
this.model.setConversation(known);
}
this.live = new LiveStream(client, `/api/conversations/${id}/events`, { this.live = new LiveStream(client, `/api/conversations/${id}/events`, {
onEvent: (event) => this.model.apply(event), onEvent: (event) => this.model.apply(event),
prepare: () => this.refresh(), prepare: () => this.refresh(),
@@ -28,7 +41,9 @@ export class ConversationFeed {
return; return;
} }
try { 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; this.error = null;
} catch (error) { } catch (error) {
this.error = error instanceof Error ? error.message : String(error); this.error = error instanceof Error ? error.message : String(error);
+28
View File
@@ -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}`;
+65
View File
@@ -2,11 +2,16 @@ import type { ApiClient } from "$lib/api/client";
import { LiveStream } from "$lib/api/live.svelte"; import { LiveStream } from "$lib/api/live.svelte";
import type { BusEvent, ConversationSummary } from "$lib/api/types"; import type { BusEvent, ConversationSummary } from "$lib/api/types";
import type { PanelCache } from "./cache"; import type { PanelCache } from "./cache";
import { cacheableHistory, historyKey, infoKey } from "./history-cache";
const RELOAD_DEBOUNCE_MS = 1500; const RELOAD_DEBOUNCE_MS = 1500;
const PAGE = 500; const PAGE = 500;
const CACHED_ROWS = 200; const CACHED_ROWS = 200;
const INDEX_KEY = "index"; 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 // The conversation index kept fresh by ``/api/events``: rows land as
// ``conversation.*`` events arrive, turn markers flip ``running_turn`` // ``conversation.*`` events arrive, turn markers flip ``running_turn``
@@ -19,6 +24,8 @@ export class ConversationIndex {
protected readonly client: () => ApiClient | null; protected readonly client: () => ApiClient | null;
private readonly cache: PanelCache | null; private readonly cache: PanelCache | null;
private reloadTimer: ReturnType<typeof setTimeout> | null = null; private reloadTimer: ReturnType<typeof setTimeout> | null = null;
private readonly warmed = new Map<string, number>();
private warming: Promise<void> | null = null;
constructor(client: () => ApiClient | null, cache: PanelCache | null = null) { constructor(client: () => ApiClient | null, cache: PanelCache | null = null) {
this.client = client; this.client = client;
@@ -55,6 +62,64 @@ export class ConversationIndex {
this.conversations = list.conversations; this.conversations = list.conversations;
this.loaded = true; this.loaded = true;
this.cache?.set(INDEX_KEY, list.conversations.slice(0, CACHED_ROWS)); 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 { apply(event: BusEvent): void {
+47 -5
View File
@@ -3,8 +3,13 @@
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import { linkOf, renderMarkdown } from "./markdown"; import { linkOf, renderMarkdown } from "./markdown";
let { text, class: className = "" }: { text: string; class?: string } = let {
$props(); text,
class: className = "",
eager = true,
}: { text: string; class?: string; eager?: boolean } = $props();
const NEAR = "1200px";
const host = usePanelHost(); const host = usePanelHost();
const html = $derived(host.markdown ? "" : renderMarkdown(text)); 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) { 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 { return {
destroy() { destroy() {
watcher?.disconnect();
dispose?.(); dispose?.();
}, },
update(next: string) { update(next: string) {
dispose?.(); if (watcher) {
dispose = host.markdown?.(node, next); later(next);
} else {
now(next);
}
}, },
}; };
} }
+7
View File
@@ -132,6 +132,7 @@
} }
const refresh = () => index.load().catch(() => undefined); 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. // What the agent did on its own that day: digests and scheduled runs.
function quietSummary(group: DayGroup): string { function quietSummary(group: DayGroup): string {
@@ -208,6 +209,7 @@
{now} {now}
onChanged={refresh} onChanged={refresh}
onclick={onOpen} onclick={onOpen}
onWarm={warm}
{row} {row}
/> />
{/each} {/each}
@@ -283,6 +285,7 @@
{now} {now}
onChanged={refresh} onChanged={refresh}
onclick={onOpen} onclick={onOpen}
onWarm={warm}
row={group.master} row={group.master}
/> />
{/if} {/if}
@@ -295,6 +298,7 @@
{now} {now}
onChanged={refresh} onChanged={refresh}
onclick={onOpen} onclick={onOpen}
onWarm={warm}
{row} {row}
/> />
{/each} {/each}
@@ -307,6 +311,7 @@
{now} {now}
onChanged={refresh} onChanged={refresh}
onclick={onOpen} onclick={onOpen}
onWarm={warm}
{row} {row}
/> />
{/each} {/each}
@@ -319,6 +324,7 @@
{now} {now}
onChanged={refresh} onChanged={refresh}
onclick={onOpen} onclick={onOpen}
onWarm={warm}
{row} {row}
/> />
{/each} {/each}
@@ -347,6 +353,7 @@
{now} {now}
onChanged={refresh} onChanged={refresh}
onclick={onOpen} onclick={onOpen}
onWarm={warm}
{row} {row}
/> />
{/each} {/each}
+4
View File
@@ -79,6 +79,7 @@
const href = (id: string) => `${base}/conversations/${id}`; const href = (id: string) => `${base}/conversations/${id}`;
const GRAPH_FILES = 24; const GRAPH_FILES = 24;
const refresh = () => gateway.load().catch(() => undefined); const refresh = () => gateway.load().catch(() => undefined);
const warm = (id: string) => gateway.prefetch([id]);
async function loadVault(masterId: string) { async function loadVault(masterId: string) {
const { client } = session; const { client } = session;
@@ -414,6 +415,7 @@
{now} {now}
onChanged={refresh} onChanged={refresh}
onclick={open} onclick={open}
onWarm={warm}
open={Boolean(info?.question)} open={Boolean(info?.question)}
{row} {row}
state="waiting" state="waiting"
@@ -449,6 +451,7 @@
{now} {now}
onChanged={refresh} onChanged={refresh}
onclick={open} onclick={open}
onWarm={warm}
{row} {row}
state="running" state="running"
/> />
@@ -532,6 +535,7 @@
{now} {now}
onChanged={refresh} onChanged={refresh}
onclick={open} onclick={open}
onWarm={warm}
{row} {row}
state="quiet" state="quiet"
/> />
+4
View File
@@ -21,6 +21,7 @@
onclick, onclick,
client = null, client = null,
onChanged, onChanged,
onWarm,
}: { }: {
row: ConversationSummary; row: ConversationSummary;
href?: string; href?: string;
@@ -36,6 +37,8 @@
// press) with the conversation's actions. // press) with the conversation's actions.
client?: ApiClient | null; client?: ApiClient | null;
onChanged?: () => void; onChanged?: () => void;
// The pointer is over the strip: a good moment to fetch what it opens.
onWarm?: (id: string) => void;
} = $props(); } = $props();
const TITLE_MAX = 72; const TITLE_MAX = 72;
@@ -82,6 +85,7 @@
data-state={state} data-state={state}
href={href ?? undefined} href={href ?? undefined}
onclick={activate} onclick={activate}
onpointerenter={onWarm ? () => onWarm(row.id) : undefined}
role={href ? undefined : "button"} role={href ? undefined : "button"}
tabindex="0" tabindex="0"
this={tag} this={tag}