summaryrefslogtreecommitdiffhomepage
path: root/src/app/store.svelte.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/store.svelte.ts')
-rw-r--r--src/app/store.svelte.ts83
1 files changed, 74 insertions, 9 deletions
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();