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 20568915db
commit 9080eb7dee
10 changed files with 192 additions and 16 deletions
+2 -1
View File
@@ -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<JobsResponse | null>(cache.get<JobsResponse>(JOBS_KEY) ?? null);
constructor() {
super(() => session.client);
super(() => session.client, browserHost().cache ?? null);
}
async refreshJobs(): Promise<void> {
+14 -7
View File
@@ -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<HistoryMessage[]>(
`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"}
+4 -1
View File
@@ -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);
+17 -2
View File
@@ -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<ConversationInfo>(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);
+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 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<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) {
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 {
+47 -5
View File
@@ -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);
}
},
};
}
+7
View File
@@ -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}
+4
View File
@@ -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"
/>
+4
View File
@@ -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}