summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/App.svelte111
-rw-r--r--src/app/App.test.ts94
-rw-r--r--src/app/store.svelte.ts244
-rw-r--r--src/app/store.test.ts363
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);
+ }}
+ >
+ &times;
+ </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();
});