summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte45
-rw-r--r--src/app/App.test.ts9
-rw-r--r--src/app/store.svelte.ts83
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();