diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 45 | ||||
| -rw-r--r-- | src/app/App.test.ts | 9 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 83 |
3 files changed, 124 insertions, 13 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index f02797e..dae6177 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,8 +1,14 @@ <script lang="ts"> import type { InvokeMessage } from "@dispatch/ui-contract"; import Table from "../components/Table.svelte"; + import { + CacheWarmingView, + manifest as cacheWarmingManifest, + type WarmFeedback, + } from "../features/cache-warming"; import { ChatView, Composer, manifest as chatManifest, ModelSelector } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; + import { manifest as markdownManifest } from "../features/markdown"; import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host"; import { manifest as tabsManifest, TabBar } from "../features/tabs"; import { manifest as viewsManifest, ViewSidebar } from "../features/views"; @@ -10,15 +16,22 @@ let { store }: { store: AppStore } = $props(); + // The backend's conversation-scoped cache-warming surface. Referenced by id at + // the composition root (sanctioned discovery-by-id) to give it a dedicated view + // and keep it out of the generic Extensions surface list — SurfaceView itself + // stays fully generic (it never switches on a surface id). + const CACHE_WARMING_ID = "cache-warming"; + // The view kinds offered in the sidebar's dropdown. Generic data — the // `viewContent` snippet below maps each kind id to its renderer. const viewKinds = [ { id: "model", label: "Model" }, { id: "extensions", label: "Extensions" }, + { id: "cache-warming", label: "Cache Warming" }, ] as const; - // Default sidebar layout: a Model panel on top, Extensions below. - const initialViews = ["model", "extensions"] as const; + // Default sidebar layout: a Model panel on top, then Extensions, then Cache Warming. + const initialViews = ["model", "extensions", "cache-warming"] as const; // Frontend module list for the "Loaded Modules" view, AGGREGATED from each // feature's public `manifest` export so it can't drift from what's actually @@ -32,6 +45,8 @@ surfaceHostManifest, viewsManifest, conversationCacheManifest, + markdownManifest, + cacheWarmingManifest, ].map((m) => [m.name, m.description] as const); // Right sidebar: open by default on wide screens (pushes the chat aside), @@ -51,6 +66,19 @@ function handleSelectModel(model: string) { store.selectModel(model); } + + // Adapt the store's WarmResult to the cache-warming feature's WarmNow port. + async function warmNow(): Promise<WarmFeedback | null> { + const result = await store.warmNow(); + if (result === null) return null; + return result.ok + ? { + ok: true, + cachePct: result.response.cachePct, + expectedCacheRate: result.response.expectedCacheRate, + } + : { ok: false, error: result.error }; + } </script> <main class="relative flex h-screen overflow-hidden"> @@ -165,9 +193,20 @@ </section> <section class="mt-4 flex flex-col gap-3"> <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3> - {#each store.surfaces as spec (spec.id)} + {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID) as spec (spec.id)} <SurfaceView {spec} onInvoke={handleInvoke} /> {/each} </section> + {:else if kind === "cache-warming"} + <!-- Re-mount per conversation (like ChatView) so the view's local warming + history / manual-warm feedback can't bleed across tabs. --> + {#key store.activeConversationId} + <CacheWarmingView + spec={store.surface(CACHE_WARMING_ID)} + canWarm={store.activeConversationId !== null} + onInvoke={handleInvoke} + {warmNow} + /> + {/key} {/if} {/snippet} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 121bd20..1534d1c 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -388,7 +388,14 @@ describe("App component interaction tests", () => { // Extensions is the default view, so the modules table renders immediately. expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); - for (const name of ["chat", "tabs", "surface-host", "views", "conversation-cache"]) { + for (const name of [ + "chat", + "tabs", + "surface-host", + "views", + "conversation-cache", + "markdown", + ]) { expect(screen.getByRole("cell", { name })).toBeInTheDocument(); } diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index efbe065..c242d77 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -4,6 +4,8 @@ import type { ConversationHistoryResponse, ConversationMetricsResponse, ModelsResponse, + WarmRequest, + WarmResponse, } from "@dispatch/transport-contract"; import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; import { createIdbChunkStore } from "../adapters/idb"; @@ -12,6 +14,7 @@ import type { WebSocketLike } from "../adapters/ws"; import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws"; import { applyServerMessage, + getSurfaceSpec, type ProtocolState, initialState as protocolInitialState, invoke as protocolInvoke, @@ -30,6 +33,11 @@ import { randomId } from "./uuid"; const DEFAULT_MODEL = "opencode/deepseek-v4-flash"; +/** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */ +export type WarmResult = + | { readonly ok: true; readonly response: WarmResponse } + | { readonly ok: false; readonly error: string }; + export interface AppStore { readonly tabs: readonly Tab[]; readonly activeConversationId: string | null; @@ -40,12 +48,19 @@ export interface AppStore { /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ readonly surfaces: readonly SurfaceSpec[]; readonly lastError: ProtocolState["lastError"]; + /** The current spec for one surface by id (discovery-by-id), or null if absent. */ + surface(surfaceId: string): SurfaceSpec | null; send(text: string): void; selectModel(model: string): void; newDraft(): void; selectTab(conversationId: string): void; closeTab(conversationId: string): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; + /** + * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). + * Returns null when no conversation is focused (a draft has nothing to warm). + */ + warmNow(): Promise<WarmResult | null>; dispose(): void; } @@ -179,6 +194,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } + /** The conversation the surfaces should scope to (undefined for a draft). */ + function focusedConversationId(): string | undefined { + return tabsStore.activeConversationId ?? undefined; + } + function handleServerMessage(msg: SurfaceServerMessage): void { protocol = applyServerMessage(protocol, msg); // Surfaces are auto-expanded: whenever the catalog changes, subscribe to @@ -188,10 +208,16 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } - /** Subscribe to every catalog entry not yet subscribed; unsubscribe stragglers. */ + /** + * Subscribe to every catalog entry, scoped to the focused conversation, and + * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped + * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe` + * emits unsubscribe-old + subscribe-new); a global surface ignores the id. + */ function syncSubscriptions(): void { + const cid = focusedConversationId(); for (const entry of protocol.catalog) { - const result = protocolSubscribe(protocol, entry.id); + const result = protocolSubscribe(protocol, entry.id, cid); protocol = result.state; for (const msg of result.outgoing) { socket?.send(msg); @@ -216,11 +242,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { onMessage: handleServerMessage, onChat: handleChatMessage, onReopen() { - // The server forgot our subscriptions on reconnect; re-send for all - // catalog entries (protocolSubscribe would no-op since they're still in - // our local map, so emit the wire messages directly). - for (const entry of protocol.catalog) { - const msg: SubscribeMessage = { type: "subscribe", surfaceId: entry.id }; + // The server forgot our subscriptions on reconnect; re-send each with the + // conversation it was subscribed under (protocolSubscribe would no-op since + // they're still in our local map, so emit the wire messages directly). + for (const [surfaceId, sub] of protocol.subscriptions) { + const msg: SubscribeMessage = + sub.conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId: sub.conversationId }; socket?.send(msg); } }, @@ -292,7 +321,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get surfaces(): readonly SurfaceSpec[] { const out: SurfaceSpec[] = []; for (const entry of protocol.catalog) { - const spec = protocol.subscriptions.get(entry.id); + const spec = getSurfaceSpec(protocol, entry.id); if (spec) out.push(spec); } return out; @@ -301,6 +330,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { return protocol.lastError; }, + surface(surfaceId: string): SurfaceSpec | null { + return getSurfaceSpec(protocol, surfaceId); + }, + send(text: string): void { if (tabsStore.activeConversationId === null) { // Draft: promote to tab on first send @@ -320,6 +353,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { draftConversationId = nextDraftId; refreshActiveChat(); + // The draft became a real conversation: re-scope conversation-scoped + // surfaces (e.g. cache-warming) to its id. + syncSubscriptions(); // Now send on the promoted store chatStores.get(conversationId)?.send(text); } else { @@ -344,6 +380,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { draftStore = createChatFor(nextDraftId, activeModel); draftConversationId = nextDraftId; refreshActiveChat(); + syncSubscriptions(); }, selectTab(conversationId: string): void { @@ -353,6 +390,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { activeModel = tab.model; } refreshActiveChat(); + syncSubscriptions(); }, closeTab(conversationId: string): void { @@ -364,15 +402,42 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } void cache.delete(conversationId); refreshActiveChat(); + syncSubscriptions(); }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { - const result = protocolInvoke(protocol, surfaceId, actionId, payload); + const result = protocolInvoke( + protocol, + surfaceId, + actionId, + payload, + focusedConversationId(), + ); protocol = result.state; for (const msg of result.outgoing) { socket?.send(msg); } }, + + async warmNow(): Promise<WarmResult | null> { + const conversationId = tabsStore.activeConversationId; + if (conversationId === null) return null; + const body: WarmRequest = { conversationId, model: activeModel }; + try { + const res = await fetchImpl(`${httpBase}/chat/warm`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` }; + } + return { ok: true, response: (await res.json()) as WarmResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" }; + } + }, dispose(): void { for (const store of chatStores.values()) { store.dispose(); |
