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,
|
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> {
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 { 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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user