From 5f867c6711ed693aa2a029ae1fb07eb1106ee32c Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 7 Jun 2026 02:20:51 +0900 Subject: Slice 3 wave B: tabbed multi-conversation app + model selector (DaisyUI) - store.svelte.ts: tabs store over injected localStorage; one chat store per conversation (Map); single WS routes chat.delta/error by conversationId; draft (null active) mints a conversationId and becomes a tab on first send (title from deriveTitle); GET /models catalog; default model flash; close tab = dispose + cache.delete (local forget) + neighbour activation; restore tabs from storage + load() on construct - App.svelte: DaisyUI tab strip (+ / close), model selector, chat, surfaces - AppStore: tabs/activeConversationId/activeChat/models/activeModel + send/selectModel/newDraft/selectTab/closeTab; +localStorage inject opt Verified: svelte-check 0/0, vitest 281 (stable x2), biome clean, build ok. --- src/app/App.svelte | 111 ++++++++++----- src/app/App.test.ts | 94 ++++++++----- src/app/store.svelte.ts | 244 ++++++++++++++++++++++++++++---- src/app/store.test.ts | 363 ++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 672 insertions(+), 140 deletions(-) diff --git a/src/app/App.svelte b/src/app/App.svelte index 92939c2..811dc75 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,7 +1,7 @@ -
-

Dispatch

+
+
+

Dispatch

+
{#if store.lastError} -
+ {/if} - {#if store.chat.error} -
+ {#if store.activeChat.error} + {/if} -
-

Chat

- +
+
+ {#each store.tabs as tab (tab.conversationId)} + + {/each} + +
+
+ +
+
+ +
+ +
+ +
+ -
+
-
-

Surfaces

- {#if store.catalog.length === 0} -

No surfaces available

- {:else} -
    + {#if store.catalog.length > 0} +
    +

    Surfaces

    +
    {#each store.catalog as entry (entry.id)} -
  • - -
  • + {/each} -
- {/if} -
+
+ + {/if} {#if store.selectedSpec} -
+
{/if} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index b21b39f..8110d41 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -55,37 +55,76 @@ function fakeSocket(): FakeSocket { } function fakeFetchImpl(): typeof fetch { - return async (): Promise => - new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + return async (input: string | URL | Request): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }; +} + +function createFakeStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string): string | null { + return map.get(key) ?? null; + }, + key(_index: number): string | null { + return null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; } function sentMessages(ws: FakeSocket) { return ws.sent.map((s) => JSON.parse(s)); } +function activeConversationId(store: ReturnType): string { + const id = store.activeConversationId; + expect(id).not.toBeNull(); + return id as string; +} + describe("App component interaction tests", () => { - it("renders empty state when catalog is empty", () => { + it("renders the model selector and composer in draft mode", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); render(App, { props: { store } }); - expect(screen.getByText("No surfaces available")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Model selector" })).toBeInTheDocument(); store.dispose(); }); - it("renders a catalog button per entry after a catalog message", () => { + it("renders catalog buttons when surfaces are available", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -114,7 +153,7 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -158,7 +197,7 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -193,7 +232,7 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -227,7 +266,7 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -249,7 +288,7 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -297,31 +336,12 @@ describe("App component interaction tests", () => { store.dispose(); }); - it("renders the chat section with composer", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - }); - ws.resolveOpen(); - - render(App, { props: { store } }); - - expect(screen.getByRole("heading", { name: "Chat" })).toBeInTheDocument(); - expect(screen.getByRole("log")).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); - - store.dispose(); - }); - it("typing and sending a message posts chat.send on the socket", async () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -350,17 +370,21 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); + // Promote draft to tab + store.send("test"); + const convId = activeConversationId(store); + render(App, { props: { store } }); ws.feedServerMessage({ type: "chat.delta", event: { type: "turn-start", - conversationId: "test-conv", + conversationId: convId, turnId: "turn-1", }, }); @@ -369,7 +393,7 @@ describe("App component interaction tests", () => { type: "chat.delta", event: { type: "text-delta", - conversationId: "test-conv", + conversationId: convId, turnId: "turn-1", delta: "Hi there!", }, diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index bd3f82f..07d850b 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -1,12 +1,18 @@ -import type { ConversationHistoryResponse } from "@dispatch/transport-contract"; +import type { + ChatDeltaMessage, + ChatErrorMessage, + ConversationHistoryResponse, + ModelsResponse, +} from "@dispatch/transport-contract"; import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; import { createIdbChunkStore } from "../adapters/idb"; +import { createLocalStore } from "../adapters/local-storage"; import type { WebSocketLike } from "../adapters/ws"; import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws"; import { applyServerMessage, - initialState, type ProtocolState, + initialState as protocolInitialState, invoke as protocolInvoke, subscribe as protocolSubscribe, unsubscribe as protocolUnsubscribe, @@ -15,16 +21,29 @@ import type { ChatStore } from "../features/chat"; import { createChatStore } from "../features/chat"; import type { ConversationCache } from "../features/conversation-cache"; import { createConversationCache } from "../features/conversation-cache"; +import type { Tab, TabsState } from "../features/tabs"; +import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs"; import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; import { randomId } from "./uuid"; +const DEFAULT_MODEL = "opencode/deepseek-v4-flash"; + export interface AppStore { + readonly tabs: readonly Tab[]; + readonly activeConversationId: string | null; + readonly activeChat: ChatStore; + readonly models: readonly string[]; + readonly activeModel: string; readonly catalog: ProtocolState["catalog"]; readonly selectedId: string | null; readonly selectedSpec: SurfaceSpec | null; readonly lastError: ProtocolState["lastError"]; - readonly chat: ChatStore; + send(text: string): void; + selectModel(model: string): void; + newDraft(): void; + selectTab(conversationId: string): void; + closeTab(conversationId: string): void; select(surfaceId: string): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; dispose(): void; @@ -37,6 +56,7 @@ export interface CreateAppStoreOptions { fetchImpl?: typeof fetch; indexedDB?: IDBFactory; conversationId?: string; + localStorage?: Storage; } function createHistorySync( @@ -54,14 +74,10 @@ function createHistorySync( } export function createAppStore(opts?: CreateAppStoreOptions): AppStore { - let protocol = $state(initialState()); + let protocol = $state(protocolInitialState()); let selectedId = $state(null); - - let socket: ReturnType | null = null; - - function handleServerMessage(msg: SurfaceServerMessage): void { - protocol = applyServerMessage(protocol, msg); - } + let models = $state([]); + let activeModel = $state(DEFAULT_MODEL); const wsLocation = typeof location !== "undefined" ? location : undefined; const wsUrl = @@ -84,7 +100,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis); const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; - const conversationId = opts?.conversationId ?? randomId(); + const localStorageOpt = opts?.localStorage; + + const storageAdapter = createLocalStore("dispatch.tabs", { + storage: localStorageOpt, + }); + const tabsStore: TabsStore = createTabsStore(storageAdapter); const cache: ConversationCache = createConversationCache( createIdbChunkStore({ indexedDB: indexedDBFactory }), @@ -92,25 +113,72 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const historySync = createHistorySync(httpBase, fetchImpl); - const chatStore = createChatStore({ - conversationId, - transport: { - send(msg) { - socket?.send(msg); + const chatStores = new Map(); + + function createChatFor(conversationId: string, model: string): ChatStore { + return createChatStore({ + conversationId, + model, + transport: { + send(msg) { + socket?.send(msg); + }, }, - }, - historySync, - cache, - }); + historySync, + cache, + }); + } + + const initialDraftId = randomId(); + let draftStore: ChatStore = createChatFor(initialDraftId, activeModel); + let draftConversationId: string = initialDraftId; + + let activeChat = $state(draftStore as ChatStore); + + function getActiveChat(): ChatStore { + const activeId = tabsStore.activeConversationId; + if (activeId === null) { + return draftStore; + } + return chatStores.get(activeId) ?? draftStore; + } + + function refreshActiveChat(): void { + activeChat = getActiveChat(); + } + + function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void { + let targetId: string | undefined; + if (msg.type === "chat.delta") { + targetId = msg.event.conversationId; + } else { + targetId = msg.conversationId; + } + + if (targetId !== undefined) { + const store = chatStores.get(targetId); + if (store !== undefined) { + store.handleDelta(msg); + return; + } + } + + // fallback: try all stores (chat.error without conversationId) + for (const store of chatStores.values()) { + store.handleDelta(msg); + } + } + + function handleServerMessage(msg: SurfaceServerMessage): void { + protocol = applyServerMessage(protocol, msg); + } - let chat = $state(chatStore as ChatStore); + let socket: ReturnType | null = null; const socketOpts: SurfaceSocketOptions = { url: wsUrl, onMessage: handleServerMessage, - onChat(msg) { - chatStore.handleDelta(msg); - }, + onChat: handleChatMessage, onReopen() { if (selectedId !== null) { const result = protocolSubscribe(protocol, selectedId); @@ -126,9 +194,62 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } socket = createSurfaceSocket(socketOpts); - void chatStore.load(); + // Fetch model catalog + void fetchImpl(`${httpBase}/models`) + .then((res) => { + if (!res.ok) return; + return res.json() as Promise; + }) + .then((data) => { + if (data === undefined) return; + models = data.models; + if (data.models.length > 0 && !data.models.includes(activeModel)) { + const first = data.models[0]; + if (first !== undefined) { + activeModel = first; + } + } + }) + .catch(() => { + // Model fetch failure is non-fatal; use defaults. + }); + + // Restore persisted tabs + const persistedState = storageAdapter.load(); + if (persistedState !== null && persistedState.tabs.length > 0) { + for (const tab of persistedState.tabs) { + const store = createChatFor(tab.conversationId, tab.model); + chatStores.set(tab.conversationId, store); + void store.load(); + } + if (persistedState.activeConversationId !== null) { + const activeTab = persistedState.tabs.find( + (t) => t.conversationId === persistedState.activeConversationId, + ); + if (activeTab !== undefined) { + activeModel = activeTab.model; + } + } + } + + refreshActiveChat(); return { + get tabs(): readonly Tab[] { + return tabsStore.tabs; + }, + get activeConversationId(): string | null { + return tabsStore.activeConversationId; + }, + get activeChat(): ChatStore { + return activeChat; + }, + get models(): readonly string[] { + return models; + }, + get activeModel(): string { + return activeModel; + }, get catalog() { return protocol.catalog; }, @@ -142,9 +263,72 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get lastError() { return protocol.lastError; }, - get chat() { - return chat; + + send(text: string): void { + if (tabsStore.activeConversationId === null) { + // Draft: promote to tab on first send + const conversationId = draftConversationId; + const model = activeModel; + tabsStore.createTab({ + conversationId, + model, + title: deriveTitle(text), + }); + chatStores.set(conversationId, draftStore); + void draftStore.load(); + + // Prepare next draft + const nextDraftId = randomId(); + draftStore = createChatFor(nextDraftId, activeModel); + draftConversationId = nextDraftId; + + refreshActiveChat(); + // Now send on the promoted store + chatStores.get(conversationId)?.send(text); + } else { + activeChat.send(text); + } + }, + + selectModel(model: string): void { + activeModel = model; + const activeId = tabsStore.activeConversationId; + if (activeId !== null) { + tabsStore.setModel(activeId, model); + chatStores.get(activeId)?.setModel(model); + } else { + draftStore.setModel(model); + } + }, + + newDraft(): void { + tabsStore.newDraft(); + const nextDraftId = randomId(); + draftStore = createChatFor(nextDraftId, activeModel); + draftConversationId = nextDraftId; + refreshActiveChat(); }, + + selectTab(conversationId: string): void { + tabsStore.selectTab(conversationId); + const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId); + if (tab !== undefined) { + activeModel = tab.model; + } + refreshActiveChat(); + }, + + closeTab(conversationId: string): void { + tabsStore.closeTab(conversationId); + const store = chatStores.get(conversationId); + if (store !== undefined) { + store.dispose(); + chatStores.delete(conversationId); + } + void cache.delete(conversationId); + refreshActiveChat(); + }, + select(surfaceId: string): void { if (selectedId !== null && selectedId !== surfaceId) { const unsub = protocolUnsubscribe(protocol, selectedId); @@ -168,7 +352,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, dispose(): void { - chatStore.dispose(); + for (const store of chatStores.values()) { + store.dispose(); + } + chatStores.clear(); + draftStore.dispose(); socket?.close(); socket = null; }, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 7b00d42..dabc80d 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -51,11 +51,21 @@ function fakeSocket(): FakeSocket { return ws; } -function fakeFetchImpl(responses: Record = {}): typeof fetch { +interface FakeFetchOptions { + models?: readonly string[]; + history?: Record; +} + +function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { + const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; + const history = opts?.history ?? {}; return async (input: string | URL | Request): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models }), { status: 200 }); + } const body = - responses[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); + history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); return new Response(JSON.stringify(body), { status: 200 }); }; } @@ -64,6 +74,36 @@ function parseSent(ws: FakeSocket): unknown[] { return ws.sent.map((s) => JSON.parse(s)); } +function createFakeStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string): string | null { + return map.get(key) ?? null; + }, + key(_index: number): string | null { + return null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; +} + +function activeConversationId(store: ReturnType): string { + const id = store.activeConversationId; + expect(id).not.toBeNull(); + return id as string; +} + describe("createAppStore", () => { it("starts with empty catalog and no selection", () => { const ws = fakeSocket(); @@ -71,6 +111,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -88,6 +129,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -112,6 +154,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -139,6 +182,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -175,6 +219,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -208,6 +253,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -234,6 +280,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -261,6 +308,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -268,41 +316,45 @@ describe("createAppStore", () => { expect(closeSpy.called).toBe(true); }); - it("exposes chat store with empty initial messages", () => { + it("exposes activeChat with empty initial messages", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); - expect(store.chat).toBeDefined(); - expect(store.chat.messages).toEqual([]); - expect(store.chat.chunks).toEqual([]); - expect(store.chat.error).toBeNull(); + expect(store.activeChat).toBeDefined(); + expect(store.activeChat.messages).toEqual([]); + expect(store.activeChat.chunks).toEqual([]); + expect(store.activeChat.error).toBeNull(); store.dispose(); }); - it("sending a message posts a chat.send on the socket", () => { + it("sending a message from draft creates a tab and posts chat.send", () => { const ws = fakeSocket(); + const storage = createFakeStorage(); const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: storage, }); ws.resolveOpen(); ws.sent.length = 0; - store.chat.send("hello world"); + store.send("hello world"); + + expect(store.tabs).toHaveLength(1); + expect(store.tabs[0]?.title).toBe("hello world"); + expect(store.activeConversationId).not.toBeNull(); const msgs = parseSent(ws); const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as | { type: string; conversationId: string; message: string } | undefined; expect(chatSend).toBeTruthy(); - expect(chatSend?.conversationId).toBe("test-conv"); expect(chatSend?.message).toBe("hello world"); store.dispose(); @@ -313,41 +365,30 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); + store.send("test"); + const convId = activeConversationId(store); + ws.feedServerMessage({ type: "chat.delta", - event: { - type: "turn-start", - conversationId: "test-conv", - turnId: "turn-1", - }, + event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, }); ws.feedServerMessage({ type: "chat.delta", - event: { - type: "text-delta", - conversationId: "test-conv", - turnId: "turn-1", - delta: "Hello ", - }, + event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " }, }); ws.feedServerMessage({ type: "chat.delta", - event: { - type: "text-delta", - conversationId: "test-conv", - turnId: "turn-1", - delta: "world", - }, + event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" }, }); - expect(store.chat.chunks.length).toBeGreaterThan(0); - const textChunks = store.chat.chunks.filter((c) => c.chunk.type === "text"); + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + const textChunks = store.activeChat.chunks.filter((c) => c.chunk.type === "text"); expect(textChunks).toHaveLength(1); expect((textChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world"); @@ -359,16 +400,20 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); + store.send("test"); + const convId = activeConversationId(store); + ws.feedServerMessage({ type: "chat.error", + conversationId: convId, message: "bad request", }); - expect(store.chat.error).toBe("bad request"); + expect(store.activeChat.error).toBe("bad request"); store.dispose(); }); @@ -385,6 +430,11 @@ describe("createAppStore", () => { const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; fetchedUrls.push(url); + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } return new Response(JSON.stringify(historyResponse), { status: 200 }); }; @@ -392,36 +442,257 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl, - conversationId: "test-conv", httpUrl: "http://localhost:24203", + localStorage: createFakeStorage(), }); ws.resolveOpen(); + store.send("hi"); + const convId = activeConversationId(store); + ws.feedServerMessage({ type: "chat.delta", - event: { - type: "turn-start", - conversationId: "test-conv", - turnId: "turn-1", - }, + event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + + store.dispose(); + }); + + it("fetches and exposes the model catalog", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ + models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], + }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await new Promise((r) => setTimeout(r, 50)); + + expect(store.models).toEqual([ + "opencode/deepseek-v4-flash", + "openai/gpt-4o", + "anthropic/claude-3", + ]); + + store.dispose(); + }); + + it("default model is flash", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + + store.dispose(); + }); + + it("draft: sending the first message creates a tab titled from the message", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + + store.send("What is the meaning of life?"); + + expect(store.tabs).toHaveLength(1); + expect(store.tabs[0]?.title).toBe("What is the meaning of life?"); + expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId); + + store.dispose(); + }); + + it("selecting a model updates the active tab", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("hello"); + + store.selectModel("openai/gpt-4o"); + + expect(store.activeModel).toBe("openai/gpt-4o"); + expect(store.tabs[0]?.model).toBe("openai/gpt-4o"); + + store.dispose(); + }); + + it("chat.delta routes to the matching tab only", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), }); + ws.resolveOpen(); + + store.send("first message"); + const convId1 = activeConversationId(store); + + store.newDraft(); + store.send("second message"); + const convId2 = activeConversationId(store); + + expect(convId1).not.toBe(convId2); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" }, + }); ws.feedServerMessage({ type: "chat.delta", event: { - type: "turn-sealed", - conversationId: "test-conv", + type: "text-delta", + conversationId: convId1, turnId: "turn-1", + delta: "response to first", }, }); - await new Promise((r) => setTimeout(r, 50)); + store.selectTab(convId1); + const textChunks1 = store.activeChat.chunks.filter((c) => c.chunk.type === "text"); + expect(textChunks1).toHaveLength(1); + expect((textChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe( + "response to first", + ); - expect(fetchedUrls.some((u) => u.includes("/conversations/test-conv?sinceSeq="))).toBe(true); + store.selectTab(convId2); + expect(store.activeChat.chunks).toEqual([]); - await new Promise((r) => setTimeout(r, 50)); + store.dispose(); + }); + + it("closing a tab evicts its cache and drops the tab", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + expect(store.tabs).toHaveLength(1); + + store.closeTab(convId); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + + store.dispose(); + }); + + it("tabs persist to the injected storage and restore on a new store", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + store.send("persist me"); + const convId = store.tabs[0]?.conversationId; + const title = store.tabs[0]?.title; + expect(convId).toBeDefined(); + expect(title).toBeDefined(); + + const raw = storage.getItem("dispatch.tabs"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw as string); + expect(parsed.tabs).toHaveLength(1); + expect(parsed.tabs[0].conversationId).toBe(convId); + expect(parsed.tabs[0].title).toBe(title); + + const ws2 = fakeSocket(); + const store2 = createAppStore({ + socketFactory: () => ws2, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws2.resolveOpen(); + + expect(store2.tabs).toHaveLength(1); + expect(store2.tabs[0]?.conversationId).toBe(convId); + expect(store2.tabs[0]?.title).toBe(title); + expect(store2.activeConversationId).toBe(convId); + + store.dispose(); + store2.dispose(); + }); + + it("newDraft resets to draft mode", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + expect(store.tabs).toHaveLength(1); + + store.newDraft(); + expect(store.activeConversationId).toBeNull(); + + store.dispose(); + }); + + it("selectTab switches active tab", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId1 = activeConversationId(store); + + store.newDraft(); + store.send("second"); + const convId2 = activeConversationId(store); + + store.selectTab(convId1); + expect(store.activeConversationId).toBe(convId1); - expect(store.chat.chunks.length).toBeGreaterThan(0); + store.selectTab(convId2); + expect(store.activeConversationId).toBe(convId2); store.dispose(); }); -- cgit v1.2.3