summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-03 13:02:15 +0900
committerAdam Malczewski <[email protected]>2026-06-03 13:02:15 +0900
commite87e6b39285c8001045d1ebdac873b182c0f7868 (patch)
tree27003852f7b182fd65c6ad762784aa5fcf839ebc /packages/frontend/src/lib
parentae672fd4f5542a2c217cf97657bf81eeebdaabbd (diff)
downloaddispatch-e87e6b39285c8001045d1ebdac873b182c0f7868.tar.gz
dispatch-e87e6b39285c8001045d1ebdac873b182c0f7868.zip
feat: prompt cache warming for idle tabs
Keep a tab's provider prompt-cache warm while idle by periodically replaying the exact cached conversation prefix plus a single trivial throwaway turn, resetting the provider's ~5-min cache TTL so the user's next real message hits a warm cache. Backend: - Agent.warmCache(history): extracts buildLlmContext() shared with run(), then re-sends the identical system+tools+history prefix (same Anthropic cache_control breakpoints) plus a 'reply with just a .' probe turn via toolChoice:none. Returns the request usage; mutates no history, emits/persists nothing. - AgentManager.warmCacheForTab(): resolves the same agent the next real turn would use, replays the FULL genuine history, refuses while a turn is running. - POST /chat/warm: returns ONLY the warming request's usage (never persisted, never folded into the real usage aggregate). Frontend: - cache-warming.svelte.ts store: per-tab 4-min repeating idle timer with countdown, warming-specific last-request cache %, and error capture. Arms on turn end, pauses during a turn, disables+resets on a real user message. - cache-warm-storage.ts: per-tab localStorage persistence of the toggle. - Lifecycle hooks wired into tabs.svelte.ts (status/statuses/sendMessage/ hydrate/create/open/close). - ModelSelector: bottom-of-panel checkbox + debug strip (last-% / countdown / error), shown only when enabled. Warming cache data never touches the real Cache Rate view. Tests: core warmCache (5), api warm route (3) + warmCacheForTab (3), frontend store (12) + storage (10). check / test (779) / frontend build / typecheck all green.
Diffstat (limited to 'packages/frontend/src/lib')
-rw-r--r--packages/frontend/src/lib/cache-warm-storage.ts77
-rw-r--r--packages/frontend/src/lib/cache-warming.svelte.ts311
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte89
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte3
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts49
5 files changed, 529 insertions, 0 deletions
diff --git a/packages/frontend/src/lib/cache-warm-storage.ts b/packages/frontend/src/lib/cache-warm-storage.ts
new file mode 100644
index 0000000..66a8c31
--- /dev/null
+++ b/packages/frontend/src/lib/cache-warm-storage.ts
@@ -0,0 +1,77 @@
+/**
+ * LocalStorage persistence for the per-tab "prompt cache warming" toggle.
+ *
+ * Cache warming keeps a tab's provider prompt-cache warm while the tab is idle
+ * by periodically replaying its exact cached conversation plus a trivial
+ * throwaway turn (see `cache-warming.svelte.ts`). Whether warming is enabled is
+ * a per-tab preference that must survive a browser reload.
+ *
+ * Why localStorage and not the backend `settings` table:
+ * - It's a per-device UI preference, not domain state — the same precedent as
+ * `dispatch-sidebar-panels`, `dispatch-theme`, and `dispatch-api-url`.
+ * - No backend round-trip on every toggle.
+ * - Warming itself is a frontend-driven timer; keeping its on/off flag on the
+ * frontend keeps the whole feature self-contained.
+ *
+ * Shape: a single JSON object mapping `tabId -> boolean` under one key, so a
+ * closed tab's stale entry is cheap and easy to prune.
+ */
+
+const LS_KEY = "dispatch-cache-warming";
+
+type WarmMap = Record<string, boolean>;
+
+/** Read the whole tab→enabled map. Never throws; returns {} on any failure. */
+function readMap(): WarmMap {
+ try {
+ const raw = localStorage.getItem(LS_KEY);
+ if (!raw) return {};
+ const parsed: unknown = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
+ const out: WarmMap = {};
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
+ if (typeof v === "boolean") out[k] = v;
+ }
+ return out;
+ } catch {
+ // localStorage unavailable (private mode / restricted context) or corrupt
+ // write from a prior session. Degrade to "nothing persisted".
+ return {};
+ }
+}
+
+/** Persist the whole map. Best-effort — swallows quota / access errors. */
+function writeMap(map: WarmMap): void {
+ try {
+ localStorage.setItem(LS_KEY, JSON.stringify(map));
+ } catch {
+ // Best-effort: the in-memory store remains the source of truth for the
+ // current session even if persistence fails.
+ }
+}
+
+/** Whether cache warming is enabled for `tabId` (default: false). */
+export function loadCacheWarmEnabled(tabId: string): boolean {
+ return readMap()[tabId] === true;
+}
+
+/**
+ * Persist the warming toggle for one tab. Writing `false` removes the entry
+ * (default is off, so absence is the natural "disabled" representation and
+ * keeps stale tabs from accumulating).
+ */
+export function saveCacheWarmEnabled(tabId: string, enabled: boolean): void {
+ const map = readMap();
+ if (enabled) map[tabId] = true;
+ else delete map[tabId];
+ writeMap(map);
+}
+
+/** Drop a tab's persisted toggle entirely (called when the tab is closed). */
+export function clearCacheWarmEnabled(tabId: string): void {
+ const map = readMap();
+ if (tabId in map) {
+ delete map[tabId];
+ writeMap(map);
+ }
+}
diff --git a/packages/frontend/src/lib/cache-warming.svelte.ts b/packages/frontend/src/lib/cache-warming.svelte.ts
new file mode 100644
index 0000000..cda3fd1
--- /dev/null
+++ b/packages/frontend/src/lib/cache-warming.svelte.ts
@@ -0,0 +1,311 @@
+/**
+ * Prompt-cache WARMING — frontend timer/orchestration store.
+ *
+ * Keeps a tab's provider prompt-cache warm while the tab is IDLE by firing a
+ * cheap "warm" request (`POST /chat/warm`) on a repeating ~4-minute cadence.
+ * The backend replays the tab's EXACT cached prefix plus one trivial throwaway
+ * turn (see `Agent.warmCache`), which registers a cache READ and refreshes the
+ * provider's ~5-min prompt-cache TTL so the user's next real message lands on a
+ * warm cache.
+ *
+ * Lifecycle (driven by the tab store via the `onTurn*` / `onUserMessage` hooks):
+ * - A turn ENDS (tab goes idle) → arm: schedule a fire in 4 minutes.
+ * - The timer fires → warm, then re-arm 4 minutes out
+ * (repeats; resets the countdown each
+ * cycle).
+ * - A turn is ONGOING (generation active) → never fires; the pending timer is
+ * cancelled.
+ * - The user sends a real message → disable+reset the timer immediately;
+ * the turn it starts re-arms warming
+ * once it ends.
+ *
+ * CRITICAL: the warming request is debug-only. Its cache data is surfaced ONLY
+ * as a warming-specific "Last request" percentage here — it is NEVER folded
+ * into the real Cache Rate metric, never persisted, never counted toward
+ * context. The backend route returns just the request's `usage`; nothing else.
+ */
+
+import type { AgentModelEntry } from "@dispatch/core/src/types/index.js";
+import {
+ clearCacheWarmEnabled,
+ loadCacheWarmEnabled,
+ saveCacheWarmEnabled,
+} from "./cache-warm-storage.js";
+import { config } from "./config.js";
+
+/** Re-warm cadence. Comfortably under Claude's ~5-min prompt-cache expiry. */
+export const WARM_INTERVAL_MS = 4 * 60 * 1000;
+
+/** Per-tab request parameters the warm POST needs (resolved from the tab). */
+export interface WarmRequestParams {
+ keyId: string | null;
+ modelId: string | null;
+ agentModels: AgentModelEntry[] | null;
+}
+
+/** Reactive, per-tab warming UI state (read by the Chat Settings debug strip). */
+export interface WarmState {
+ /** User toggle (persisted per-tab in localStorage). */
+ enabled: boolean;
+ /** Epoch ms of the next scheduled fire, or null when not armed. */
+ nextFireAt: number | null;
+ /**
+ * Cache-read % of the most recent warming request (0–100), or null if it
+ * has never fired this session. Drives the "-%" → number display.
+ */
+ lastPct: number | null;
+ /** Last warming error (provider/network), surfaced in the debug strip. */
+ error: string | null;
+ /** True while a warm request is in flight. */
+ firing: boolean;
+}
+
+function defaultState(enabled: boolean): WarmState {
+ return { enabled, nextFireAt: null, lastPct: null, error: null, firing: false };
+}
+
+function computeCachePct(inputTokens: number, cacheReadTokens: number): number {
+ if (inputTokens <= 0) return 0;
+ return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100);
+}
+
+export function createCacheWarmingStore() {
+ // Reactive per-tab state. Nested mutation is reactive via Svelte 5 proxies;
+ // new keys are assigned wholesale (also reactive).
+ const states = $state<Record<string, WarmState>>({});
+ // Ticking clock so the countdown display refreshes once per second. Only
+ // ticked while at least one tab is armed (see (re)startTicker).
+ let now = $state(Date.now());
+
+ // Non-reactive bookkeeping (timers, in-flight tokens, running set, resolver).
+ const fireTimers = new Map<string, ReturnType<typeof setTimeout>>();
+ const fireTokens = new Map<string, number>();
+ const runningTabs = new Set<string>();
+ let ticker: ReturnType<typeof setInterval> | null = null;
+ let resolveParams: ((tabId: string) => WarmRequestParams | null) | null = null;
+
+ function ensure(tabId: string): WarmState {
+ let s = states[tabId];
+ if (!s) {
+ s = defaultState(loadCacheWarmEnabled(tabId));
+ states[tabId] = s;
+ }
+ return s;
+ }
+
+ function anyArmed(): boolean {
+ for (const s of Object.values(states)) {
+ if (s.nextFireAt !== null) return true;
+ }
+ return false;
+ }
+
+ function startTickerIfNeeded(): void {
+ if (ticker !== null) return;
+ if (typeof setInterval !== "function") return;
+ ticker = setInterval(() => {
+ now = Date.now();
+ // Self-stop once nothing is armed, so we don't tick forever.
+ if (!anyArmed()) stopTicker();
+ }, 1000);
+ }
+
+ function stopTicker(): void {
+ if (ticker !== null) {
+ clearInterval(ticker);
+ ticker = null;
+ }
+ }
+
+ function clearFireTimer(tabId: string): void {
+ const t = fireTimers.get(tabId);
+ if (t !== undefined) {
+ clearTimeout(t);
+ fireTimers.delete(tabId);
+ }
+ }
+
+ /** Cancel any pending fire / in-flight request and clear the countdown. */
+ function cancel(tabId: string): void {
+ clearFireTimer(tabId);
+ // Invalidate any in-flight warm so its late result is ignored.
+ fireTokens.set(tabId, (fireTokens.get(tabId) ?? 0) + 1);
+ const s = states[tabId];
+ if (s) s.nextFireAt = null;
+ if (!anyArmed()) stopTicker();
+ }
+
+ /** Schedule the next fire 4 minutes out — only when enabled AND idle. */
+ function arm(tabId: string): void {
+ const s = ensure(tabId);
+ if (!s.enabled) return;
+ if (runningTabs.has(tabId)) return;
+ clearFireTimer(tabId);
+ s.nextFireAt = Date.now() + WARM_INTERVAL_MS;
+ startTickerIfNeeded();
+ if (typeof setTimeout === "function") {
+ fireTimers.set(
+ tabId,
+ setTimeout(() => {
+ fireTimers.delete(tabId);
+ void fire(tabId);
+ }, WARM_INTERVAL_MS),
+ );
+ }
+ }
+
+ /** Perform one warming request, then (if still eligible) re-arm. */
+ async function fire(tabId: string): Promise<void> {
+ const s = ensure(tabId);
+ if (!s.enabled || runningTabs.has(tabId) || s.firing) {
+ return;
+ }
+ const token = (fireTokens.get(tabId) ?? 0) + 1;
+ fireTokens.set(tabId, token);
+ const params = resolveParams?.(tabId) ?? null;
+
+ s.firing = true;
+ s.error = null;
+ // Clear the countdown while the request is in flight.
+ s.nextFireAt = null;
+ try {
+ const res = await fetch(`${config.apiBase}/chat/warm`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ tabId,
+ ...(params?.keyId ? { keyId: params.keyId } : {}),
+ ...(params?.modelId ? { modelId: params.modelId } : {}),
+ ...(params?.agentModels ? { agentModels: params.agentModels } : {}),
+ }),
+ });
+ // A newer cancel/fire superseded this request — drop its result so it
+ // can't clobber fresher state (e.g. user sent a real message meanwhile).
+ if (fireTokens.get(tabId) !== token) return;
+
+ if (!res.ok) {
+ let msg = `warm failed (HTTP ${res.status})`;
+ try {
+ const body = (await res.json()) as { error?: string };
+ if (body?.error) msg = body.error;
+ } catch {
+ /* non-JSON error body — keep the HTTP status message */
+ }
+ s.error = msg;
+ } else {
+ const data = (await res.json()) as {
+ usage?: { inputTokens?: number; cacheReadTokens?: number };
+ };
+ const u = data.usage ?? {};
+ s.lastPct = computeCachePct(u.inputTokens ?? 0, u.cacheReadTokens ?? 0);
+ s.error = null;
+ }
+ } catch (err) {
+ if (fireTokens.get(tabId) !== token) return;
+ s.error = err instanceof Error ? err.message : String(err);
+ } finally {
+ if (fireTokens.get(tabId) === token) {
+ s.firing = false;
+ // Re-arm for the next cycle (resets the 4-min countdown), but only
+ // if still enabled and the tab is still idle.
+ if (s.enabled && !runningTabs.has(tabId)) arm(tabId);
+ else if (!anyArmed()) stopTicker();
+ }
+ }
+ }
+
+ // ─── Public lifecycle hooks (called by the tab store) ────────────
+
+ /**
+ * Register the resolver the store uses to fetch a tab's request params
+ * (key/model/agentModels) at fire time. Called once by the tab store.
+ */
+ function setRequestResolver(fn: (tabId: string) => WarmRequestParams | null): void {
+ resolveParams = fn;
+ }
+
+ /** Seed a tab's state from persistence. Arms immediately if enabled+idle. */
+ function initTab(tabId: string): void {
+ const s = ensure(tabId);
+ if (s.enabled && !runningTabs.has(tabId) && s.nextFireAt === null) {
+ arm(tabId);
+ }
+ }
+
+ /** Toggle warming for a tab (persisted). Arms or cancels accordingly. */
+ function setEnabled(tabId: string, enabled: boolean): void {
+ const s = ensure(tabId);
+ s.enabled = enabled;
+ saveCacheWarmEnabled(tabId, enabled);
+ if (enabled) arm(tabId);
+ else cancel(tabId);
+ }
+
+ /** A turn started / generation is active — never warm during a turn. */
+ function onTurnActive(tabId: string): void {
+ runningTabs.add(tabId);
+ cancel(tabId);
+ }
+
+ /** A turn ended (tab idle) — re-arm the 4-minute countdown if enabled. */
+ function onTurnEnded(tabId: string): void {
+ runningTabs.delete(tabId);
+ const s = ensure(tabId);
+ if (s.enabled) arm(tabId);
+ }
+
+ /**
+ * The user sent a real message — disable+reset the timer immediately. The
+ * turn this message starts will re-arm warming via `onTurnEnded` once it
+ * settles, so the real message lands on a cache with no throwaway turns.
+ */
+ function onUserMessage(tabId: string): void {
+ cancel(tabId);
+ }
+
+ /** Forget a closed tab's timers/state. */
+ function removeTab(tabId: string): void {
+ cancel(tabId);
+ fireTimers.delete(tabId);
+ fireTokens.delete(tabId);
+ runningTabs.delete(tabId);
+ delete states[tabId];
+ if (!anyArmed()) stopTicker();
+ }
+
+ /**
+ * Forget a tab AND drop its persisted preference — for an explicit user
+ * close/archive. (`removeTab` keeps the persisted flag so an ephemeral
+ * idle-cleanup or a later reopen restores the user's choice.)
+ */
+ function forgetTab(tabId: string): void {
+ removeTab(tabId);
+ clearCacheWarmEnabled(tabId);
+ }
+
+ /** Reactive state for a tab (creates a default-off entry if absent). */
+ function stateFor(tabId: string | null | undefined): WarmState {
+ if (!tabId) return defaultState(false);
+ return ensure(tabId);
+ }
+
+ return {
+ setRequestResolver,
+ initTab,
+ setEnabled,
+ onTurnActive,
+ onTurnEnded,
+ onUserMessage,
+ removeTab,
+ forgetTab,
+ stateFor,
+ /** Reactive ticking clock (epoch ms) for countdown rendering. */
+ get now() {
+ return now;
+ },
+ // Exposed for tests to drive a fire without waiting 4 minutes.
+ fireNow: fire,
+ };
+}
+
+export const cacheWarming = createCacheWarmingStore();
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
index 8601795..1e6cdf0 100644
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -10,8 +10,13 @@ const modelCache = new Map<string, string[]>();
REASONING_EFFORT_LABELS,
} from "@dispatch/core/src/types/index.js";
import type { KeyInfo } from "../types.js";
+ import {
+ cacheWarming,
+ WARM_INTERVAL_MS,
+ } from "../cache-warming.svelte.js";
import { config } from "../config.js";
import { router } from "../router.svelte.js";
+ import { tabStore } from "../tabs.svelte.js";
interface AgentInfo {
name: string;
@@ -51,6 +56,7 @@ const modelCache = new Map<string, string[]>();
const {
keys = [],
+ activeTabId = null,
activeKeyId = null,
activeModelId = null,
reasoningEffort = "max",
@@ -65,6 +71,7 @@ const modelCache = new Map<string, string[]>();
onWorkingDirectoryChange = (_dir: string | null) => {},
}: {
keys?: KeyInfo[];
+ activeTabId?: string | null;
activeKeyId?: string | null;
activeModelId?: string | null;
reasoningEffort?: string;
@@ -87,6 +94,26 @@ const modelCache = new Map<string, string[]>();
let sliderDragging = $state<number | null>(null);
let modelSearch = $state("");
+ // ─── Prompt-cache warming (debug strip lives at the bottom) ──────
+ // Reactive per-tab warming state from the singleton store. `warm.now` is a
+ // 1s ticking clock so the countdown re-renders while a fire is pending.
+ const warm = $derived(cacheWarming.stateFor(activeTabId));
+ const warmCountdown = $derived.by(() => {
+ const next = warm.nextFireAt;
+ if (next === null) return null;
+ const ms = Math.max(0, next - cacheWarming.now);
+ const total = Math.round(ms / 1000);
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return `${m}:${s.toString().padStart(2, "0")}`;
+ });
+ const warmIntervalLabel = `${Math.round(WARM_INTERVAL_MS / 60000)} min`;
+
+ function toggleCacheWarming(enabled: boolean): void {
+ if (!activeTabId) return;
+ tabStore.setCacheWarmingEnabled(activeTabId, enabled);
+ }
+
let cwdExists = $state<boolean | null>(null);
let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
@@ -434,6 +461,68 @@ const modelCache = new Map<string, string[]>();
Agent Settings
</button>
{/if}
+
+ <!-- Prompt-cache warming (bottom of the Chat Settings panel) -->
+ <div class="mt-3 pt-3 border-t border-base-300">
+ <label class="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm rounded-sm"
+ checked={warm.enabled}
+ disabled={!activeTabId}
+ onchange={(e) => toggleCacheWarming(e.currentTarget.checked)}
+ />
+ <span class="text-xs font-semibold">Keep prompt cache warm</span>
+ </label>
+ <p class="text-[10px] text-base-content/40 mt-1 leading-snug">
+ While this tab is idle, replays the cached conversation every {warmIntervalLabel}
+ so the provider cache stays warm for your next message. Warming traffic is
+ debug-only — it never touches history, the Cache Rate metric, or context size.
+ </p>
+
+ {#if warm.enabled}
+ <div class="mt-2 flex flex-col gap-2 bg-base-300/40 rounded-lg p-2">
+ <!-- Warming "last request" cache rate (separate from the real metric) -->
+ <div class="flex flex-col gap-0.5">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Last request (warming)</span>
+ <span class="text-xs font-mono">{warm.lastPct === null ? "-%" : `${warm.lastPct}%`}</span>
+ </div>
+ <progress
+ class="progress w-full h-2 {warm.lastPct === null
+ ? ''
+ : warm.lastPct >= 70
+ ? 'progress-success'
+ : warm.lastPct >= 30
+ ? 'progress-warning'
+ : 'progress-error'}"
+ value={warm.lastPct ?? 0}
+ max="100"
+ ></progress>
+ </div>
+
+ <!-- Countdown to the next warming fire -->
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Next warm in</span>
+ <span class="text-xs font-mono">
+ {#if warm.firing}
+ warming…
+ {:else if warmCountdown !== null}
+ {warmCountdown}
+ {:else}
+ —
+ {/if}
+ </span>
+ </div>
+
+ {#if warm.error}
+ <div class="text-[10px] text-error break-words">
+ {warm.error}
+ </div>
+ {/if}
+ </div>
+ {/if}
+ </div>
</div>
{#if showKeyModal}
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 519f411..ff8556a 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -31,6 +31,7 @@ const {
contextLimit = null,
permissionLog = [],
apiBase = "",
+ activeTabId = null as string | null,
activeKeyId = null,
activeModelId = null,
reasoningEffort = "max",
@@ -52,6 +53,7 @@ const {
contextLimit?: number | null;
permissionLog?: LogEntry[];
apiBase?: string;
+ activeTabId?: string | null;
activeKeyId?: string | null;
activeModelId?: string | null;
reasoningEffort?: string;
@@ -157,6 +159,7 @@ function contentClass(_selected: string): string {
{#if panel.selected === "Chat Settings"}
<ModelSelector
{keys}
+ {activeTabId}
{activeKeyId}
{activeModelId}
{reasoningEffort}
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index e33a0e9..a0125ef 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -19,6 +19,7 @@ import {
type ReasoningEffort,
} from "@dispatch/core/src/types/index.js";
import { intactTokenIds, type StagedAttachment } from "./attachment-tokens.js";
+import { cacheWarming } from "./cache-warming.svelte.js";
import { config } from "./config.js";
import { appSettings } from "./settings.svelte.js";
import type {
@@ -245,6 +246,14 @@ export function createTabStore() {
handleEvent(event as AgentEvent & { tabId?: string });
});
+ // Let the cache-warming store resolve a tab's provider request params
+ // (key/model/fallback chain) at fire time, straight from live tab state.
+ cacheWarming.setRequestResolver((tabId) => {
+ const t = getTabById(tabId);
+ if (!t) return null;
+ return { keyId: t.keyId, modelId: t.modelId, agentModels: t.agentModels };
+ });
+
$effect.root(() => {
$effect(() => {
isConnected = wsClient.connectionStatus === "connected";
@@ -327,6 +336,7 @@ export function createTabStore() {
};
tabs = [...tabs, tab];
activeTabId = id;
+ cacheWarming.initTab(id);
// Auto-check default skills then apply default agent (sequential to avoid race)
void (async () => {
@@ -405,6 +415,7 @@ export function createTabStore() {
};
tabs = [...tabs, newTab];
activeTabId = agentId;
+ cacheWarming.initTab(agentId);
evictChunks(agentId);
} catch (err) {
console.error("openAgentTab failed:", err);
@@ -415,6 +426,8 @@ export function createTabStore() {
const tab = getTabById(id);
if (!tab) return;
+ cacheWarming.forgetTab(id);
+
// Archive on backend (also stops any running agent)
try {
await fetch(`${config.apiBase}/tabs/${id}`, { method: "DELETE" });
@@ -974,6 +987,11 @@ export function createTabStore() {
// Trim each restored tab down to the chunk limit (user starts at bottom).
for (const t of restored) {
evictChunks(t.id);
+ // Seed warming from persisted per-tab preference. Arms the 4-minute
+ // countdown for idle+enabled tabs; running tabs stay paused until
+ // their next `status`/`statuses` reconcile flips them idle.
+ cacheWarming.initTab(t.id);
+ if (t.agentStatus === "running") cacheWarming.onTurnActive(t.id);
}
// Activate the first restored tab (the list is already ordered by
// `position` from the backend).
@@ -988,6 +1006,11 @@ export function createTabStore() {
case "status": {
if (!tabId) break;
updateTab(tabId, { agentStatus: event.status });
+ // Cache warming never fires mid-turn: pause it while running, and
+ // re-arm the 4-minute countdown once the turn ends (idle/error).
+ if (event.status === "running") {
+ cacheWarming.onTurnActive(tabId);
+ }
if (event.status === "idle" || event.status === "error") {
// Stop the streaming cursor immediately; the fold of the live
// tail into the sealed chunk log happens on `turn-sealed`
@@ -998,7 +1021,10 @@ export function createTabStore() {
updateTab(tabId, { currentAssistantId: null });
const tab = getTabById(tabId);
if (tab && !tab.persistent && tabId !== activeTabId) {
+ cacheWarming.removeTab(tabId);
tabs = tabs.filter((t) => t.id !== tabId);
+ } else {
+ cacheWarming.onTurnEnded(tabId);
}
}
break;
@@ -1084,6 +1110,14 @@ export function createTabStore() {
updateTab(t.id, { agentStatus: backendStatus });
}
+ // Sync cache warming to the reconciled status: pause it while a
+ // tab is (still) running, otherwise (re-)arm the idle countdown.
+ if (backendStatus === "running") {
+ cacheWarming.onTurnActive(t.id);
+ } else {
+ cacheWarming.onTurnEnded(t.id);
+ }
+
// Rehydrate the todo list from the snapshot (backend truth)
// so a reconnect/reload doesn't blank the Tasks panel.
updateTab(t.id, { tasks: snap?.tasks ?? [] });
@@ -1643,6 +1677,12 @@ export function createTabStore() {
let tab = getActiveTab();
if (!tab) return;
+ // A real user message disables+resets the warming timer immediately, so
+ // the genuine turn appends to the real history with NO throwaway turns
+ // present (it lands on the warm cache). Warming re-arms when this turn
+ // ends (see the `status` handler → cacheWarming.onTurnEnded).
+ cacheWarming.onUserMessage(tab.id);
+
// Refresh agent config to pick up any changes made in AgentBuilder
if (tab.agentSlug && tab.agentScope) {
await refreshAgentConfig(tab.id);
@@ -1902,6 +1942,14 @@ export function createTabStore() {
updateTab(tab.id, { workingDirectory: dir || null });
}
+ /**
+ * Enable/disable prompt-cache warming for a tab (persisted per-tab). The
+ * warming store arms or cancels its 4-minute idle timer accordingly.
+ */
+ function setCacheWarmingEnabled(tabId: string, enabled: boolean): void {
+ cacheWarming.setEnabled(tabId, enabled);
+ }
+
function setAgent(
agent: {
slug: string;
@@ -2176,6 +2224,7 @@ export function createTabStore() {
promoteTab,
openAgentTab,
setWorkingDirectory,
+ setCacheWarmingEnabled,
// Exposed so tests can drive the real reactive code path that the
// WS callback uses in production. Not intended for use in
// components — they should rely on the WS subscription instead.