summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-19 17:02:40 +0900
committerAdam Malczewski <[email protected]>2026-10-19 17:02:40 +0900
commit01f9fb2a4ad6110def531ead16b370c818e8ee81 (patch)
tree398996139295c24350714bb02a88b0dc07545123 /src/app
parentc4397f633415a46852f0e3b49c78ab7b2d87d4c1 (diff)
downloaddispatch-web-01f9fb2a4ad6110def531ead16b370c818e8ee81.tar.gz
dispatch-web-01f9fb2a4ad6110def531ead16b370c818e8ee81.zip
feat(chat): cache timer for Claude Code subscription models
Per-step and per-turn timers for the gaps between model requests (the Claude prompt cache expires after 5 min idle). A step gap runs live under the executing tools from step-complete until its last tool result (or the next step's output), then is stamped on that step's metrics line beside its cache badge. A turn gap runs live on the turn's metrics line from done/error until the next message is sent, then is stamped there. Counts m:ss up to 10 min, then '10+ min'; colour tracks the 5-min TTL. Gated on claude/<model>. Pure reducer in core/metrics/cache-timer.ts; the turn gap is seeded from an idle conversation's lastActivityAt so it survives a reload. Stamps are in-memory only.
Diffstat (limited to 'src/app')
-rw-r--r--src/app/store.svelte.ts7
-rw-r--r--src/app/store.test.ts48
-rw-r--r--src/app/views/ChatPane.svelte8
3 files changed, 62 insertions, 1 deletions
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index a3bc226..fafd4d7 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -1474,6 +1474,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const model = conv.model ?? activeModel;
const store = createChatFor(conv.id, model, conv.workspaceId);
chatStores.set(conv.id, store);
+ // Seed the cache timer's turn gap (only an idle conversation is waiting on the user).
+ if (conv.status === "idle") store.noteLastActivity(conv.lastActivityAt);
void store.load();
subscribeChat(conv.id);
tabsStore.openTab({
@@ -1492,6 +1494,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// authoritative conversation record is the source of truth, so a
// differing tab workspace is corrected here.
tabsStore.setTitle(conv.id, conv.title);
+ // Seed the cache timer's turn gap from the backend (a tab restored
+ // from localStorage at boot has no live `done` to go by). Only an
+ // idle conversation is waiting on the user.
+ if (conv.status === "idle")
+ chatStores.get(conv.id)?.noteLastActivity(conv.lastActivityAt);
const tab = tabsStore.tabs.find((t) => t.conversationId === conv.id);
if (tab?.workspaceId !== conv.workspaceId) {
tabsStore.setWorkspaceId(conv.id, conv.workspaceId);
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 89e418e..675fa64 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -855,6 +855,54 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("fetchOpenConversations seeds the chat store's cache-timer turn gap from an idle conversation's lastActivityAt", async () => {
+ const ws = fakeSocket();
+ const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ {
+ id: "conv-seed",
+ createdAt: 1000,
+ lastActivityAt: 7777,
+ title: "Seed conv",
+ status: "idle",
+ workspaceId: "default",
+ model: "claude/claude-opus-4-6",
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/metrics")) {
+ return new Response(JSON.stringify({ turns: [] }), { status: 200 });
+ }
+ return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.tabs.some((t) => t.conversationId === "conv-seed")).toBe(true);
+ });
+ store.selectTab("conv-seed");
+ expect(store.activeChat.cacheTimer.running).toEqual({
+ kind: "turn",
+ turnId: null,
+ startedAt: 7777,
+ });
+
+ store.dispose();
+ });
+
it("conversation.open WS message carrying a model stamps the tab with it", async () => {
const ws = fakeSocket();
const store = createAppStore({
diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte
index 636cd7c..31e719e 100644
--- a/src/app/views/ChatPane.svelte
+++ b/src/app/views/ChatPane.svelte
@@ -7,13 +7,18 @@
* preservation, the ScrollToBottom affordance, and the empty-state
* watermark. Keyed on the active conversation exactly like today's ChatView.
*/
- import { ChatView } from "../../features/chat";
+ import { ChatView, isClaudeSubscriptionModel } from "../../features/chat";
import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll";
import { preserveViewportAcross, type AppViewPorts } from "../view-ports";
let { ports }: { ports: AppViewPorts } = $props();
const store = $derived(ports.store);
+ // Cache timer: Claude Code subscription models only (5-minute prompt-cache expiry).
+ const cacheTimer = $derived(
+ isClaudeSubscriptionModel(store.activeModel) ? store.activeChat.cacheTimer : null,
+ );
+
// One controller owns the chat scroll region; the effects below feed it the
// edges (identical to the App.svelte wiring this moved out of).
const smartScroll = createSmartScrollController();
@@ -81,6 +86,7 @@
thinkingKeyBase={store.activeChat.thinkingKeyBase}
providerRetry={store.activeChat.providerRetry}
apiBaseUrl={store.httpBase}
+ {cacheTimer}
/>
{/key}
</div>