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
+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 {