diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 111 | ||||
| -rw-r--r-- | src/app/App.test.ts | 94 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 244 | ||||
| -rw-r--r-- | 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 @@ <script lang="ts"> import type { InvokeMessage } from "@dispatch/ui-contract"; + import { ChatView, Composer, ModelSelector } from "../features/chat"; import { SurfaceView } from "../features/surface-host"; - import { ChatView, Composer } from "../features/chat"; import type { AppStore } from "./store.svelte"; let { store }: { store: AppStore } = $props(); @@ -15,56 +15,105 @@ } function handleSend(text: string) { - store.chat.send(text); + store.send(text); + } + + function handleSelectModel(model: string) { + store.selectModel(model); } </script> -<main> - <h1>Dispatch</h1> +<main class="flex h-screen flex-col"> + <div class="flex items-center justify-between border-b border-base-300 px-4 py-2"> + <h1 class="text-lg font-bold">Dispatch</h1> + </div> {#if store.lastError} - <div role="alert"> + <div role="alert" class="alert alert-error mx-4 mt-2"> <strong>Error:</strong> {store.lastError.message} </div> {/if} - {#if store.chat.error} - <div role="alert"> + {#if store.activeChat.error} + <div role="alert" class="alert alert-warning mx-4 mt-2"> <strong>Chat error:</strong> - {store.chat.error} + {store.activeChat.error} </div> {/if} - <section> - <h2>Chat</h2> - <ChatView chunks={store.chat.chunks} /> + <div class="flex items-center gap-2 border-b border-base-300 px-4"> + <div class="tabs tabs-border flex-1"> + {#each store.tabs as tab (tab.conversationId)} + <div + class="tab" + class:tab-active={tab.conversationId === store.activeConversationId} + role="tab" + tabindex="0" + onclick={() => store.selectTab(tab.conversationId)} + onkeydown={(e) => { if (e.key === "Enter") store.selectTab(tab.conversationId); }} + > + <span class="max-w-[120px] truncate">{tab.title}</span> + <button + class="btn btn-ghost btn-xs ml-1" + aria-label="Close tab" + onclick={(e) => { + e.stopPropagation(); + store.closeTab(tab.conversationId); + }} + > + × + </button> + </div> + {/each} + <button + class="tab" + class:tab-active={store.activeConversationId === null} + onclick={() => store.newDraft()} + aria-label="New chat" + > + + + </button> + </div> + </div> + + <div class="flex flex-1 flex-col overflow-hidden"> + <div class="flex items-center gap-2 px-4 py-2"> + <ModelSelector + models={store.models} + selected={store.activeModel} + onSelect={handleSelectModel} + /> + </div> + + <div class="flex-1 overflow-y-auto"> + <ChatView chunks={store.activeChat.chunks} /> + </div> + <Composer onSend={handleSend} /> - </section> + </div> - <section> - <h2>Surfaces</h2> - {#if store.catalog.length === 0} - <p>No surfaces available</p> - {:else} - <ul> + {#if store.catalog.length > 0} + <section class="border-t border-base-300 p-4"> + <h2 class="mb-2 text-sm font-semibold">Surfaces</h2> + <div class="flex flex-wrap gap-2"> {#each store.catalog as entry (entry.id)} - <li> - <button - aria-current={entry.id === store.selectedId ? "true" : undefined} - onclick={() => handleSelect(entry.id)} - > - {entry.title} - <span>({entry.region})</span> - </button> - </li> + <button + class="btn btn-sm" + class:btn-active={entry.id === store.selectedId} + aria-current={entry.id === store.selectedId ? "true" : undefined} + onclick={() => handleSelect(entry.id)} + > + {entry.title} + <span class="text-xs opacity-60">({entry.region})</span> + </button> {/each} - </ul> - {/if} - </section> + </div> + </section> + {/if} {#if store.selectedSpec} - <section> + <section class="border-t border-base-300 p-4"> <SurfaceView spec={store.selectedSpec} onInvoke={handleInvoke} /> </section> {/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<Response> => - new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + return async (input: string | URL | Request): Promise<Response> => { + 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<string, string>(); + 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<typeof createAppStore>): 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<ProtocolState>(initialState()); + let protocol = $state<ProtocolState>(protocolInitialState()); let selectedId = $state<string | null>(null); - - let socket: ReturnType<typeof createSurfaceSocket> | null = null; - - function handleServerMessage(msg: SurfaceServerMessage): void { - protocol = applyServerMessage(protocol, msg); - } + let models = $state<readonly string[]>([]); + 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<TabsState>("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<string, ChatStore>(); + + 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<ChatStore>(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>(chatStore as ChatStore); + let socket: ReturnType<typeof createSurfaceSocket> | 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<ModelsResponse>; + }) + .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<string, unknown> = {}): typeof fetch { +interface FakeFetchOptions { + models?: readonly string[]; + history?: Record<string, ConversationHistoryResponse>; +} + +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<Response> => { 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<string, string>(); + 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<typeof createAppStore>): 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<Response> => { 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(); }); |
