perf(panel): cached info and history paint first, lazy markdown up the thread, background prefetch
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user