diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 02:06:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 02:06:55 +0900 |
| commit | 529c6a2bb56447fe93796111df3d4cc5a05fdd93 (patch) | |
| tree | 8db14b4b072b8a73ac85963f625b5bb3f77883ac | |
| parent | 90c438c4562793eb09358f9d1a050d2267f4fca5 (diff) | |
| download | dispatch-web-529c6a2bb56447fe93796111df3d4cc5a05fdd93.tar.gz dispatch-web-529c6a2bb56447fe93796111df3d4cc5a05fdd93.zip | |
Slice 3 wave A: tabs model, model selector, cache delete, localStorage
- features/tabs: pure tab-workspace reducer (create/select/close/setModel/
setTitle/deriveTitle, draft=null active) + injected-persistence runes store
- features/chat: mutable per-tab model (setModel) + delta routing guard
(ignore foreign conversationId) + ModelSelector.svelte + DaisyUI chat bubbles
/ composer (keeps streaming <details> keying fix)
- features/conversation-cache: surface delete(conversationId) on the wrapper
for tab-close local-forget
- adapters/local-storage: generic injected JSON localStore<T> (quota/corrupt-safe)
Verified: svelte-check 0/0, vitest 273, biome clean, build ok.
| -rw-r--r-- | src/adapters/local-storage/index.test.ts | 120 | ||||
| -rw-r--r-- | src/adapters/local-storage/index.ts | 58 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 1 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 18 | ||||
| -rw-r--r-- | src/features/chat/store.test.ts | 89 | ||||
| -rw-r--r-- | src/features/chat/test-helpers.ts | 3 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 40 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 32 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 6 | ||||
| -rw-r--r-- | src/features/chat/ui/ModelSelector.svelte | 22 | ||||
| -rw-r--r-- | src/features/conversation-cache/cache.test.ts | 24 | ||||
| -rw-r--r-- | src/features/conversation-cache/cache.ts | 7 | ||||
| -rw-r--r-- | src/features/tabs/index.ts | 14 | ||||
| -rw-r--r-- | src/features/tabs/tabs-store.svelte.ts | 67 | ||||
| -rw-r--r-- | src/features/tabs/tabs-store.test.ts | 157 | ||||
| -rw-r--r-- | src/features/tabs/tabs.test.ts | 191 | ||||
| -rw-r--r-- | src/features/tabs/tabs.ts | 74 |
17 files changed, 902 insertions, 21 deletions
diff --git a/src/adapters/local-storage/index.test.ts b/src/adapters/local-storage/index.test.ts new file mode 100644 index 0000000..57103dd --- /dev/null +++ b/src/adapters/local-storage/index.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { createLocalStore } from "./index"; + +function createMemoryStorage(): Storage { + const map = new Map<string, string>(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string) { + return map.get(key) ?? null; + }, + key(index: number) { + return [...map.keys()][index] ?? null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; +} + +describe("createLocalStore", () => { + it("save then load round-trips an object", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<{ name: string; count: number }>("test", { storage }); + + store.save({ name: "alice", count: 42 }); + const loaded = store.load(); + + expect(loaded).toEqual({ name: "alice", count: 42 }); + }); + + it("load returns null when key is absent", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<string>("missing", { storage }); + + expect(store.load()).toBeNull(); + }); + + it("load returns null on corrupt JSON", () => { + const storage = createMemoryStorage(); + storage.setItem("corrupt", "{not valid json!!!"); + const store = createLocalStore<object>("corrupt", { storage }); + + expect(store.load()).toBeNull(); + }); + + it("clear removes the value", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<string>("key", { storage }); + + store.save("hello"); + expect(store.load()).toBe("hello"); + + store.clear(); + expect(store.load()).toBeNull(); + }); + + it("save swallows a throwing setItem (quota) without throwing", () => { + const storage = createMemoryStorage(); + const originalSetItem = storage.setItem.bind(storage); + let callCount = 0; + storage.setItem = (_key: string, _value: string) => { + callCount++; + if (callCount > 1) { + throw new DOMException("QuotaExceededError", "QuotaExceededError"); + } + originalSetItem(_key, _value); + }; + + const store = createLocalStore<number[]>("quota", { storage }); + + // First save works + store.save([1, 2, 3]); + expect(store.load()).toEqual([1, 2, 3]); + + // Second save throws but is swallowed + expect(() => store.save([4, 5, 6])).not.toThrow(); + }); + + it("construction with undefined storage yields a safe no-op store", () => { + const store = createLocalStore<string>("noop", { storage: undefined }); + + // All operations are safe no-ops + expect(store.load()).toBeNull(); + expect(() => store.save("hello")).not.toThrow(); + expect(() => store.clear()).not.toThrow(); + }); + + it("round-trips arrays", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<number[]>("arr", { storage }); + + store.save([1, 2, 3]); + expect(store.load()).toEqual([1, 2, 3]); + }); + + it("round-trips nested objects", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<{ a: { b: string[] } }>("nested", { storage }); + + store.save({ a: { b: ["x", "y"] } }); + expect(store.load()).toEqual({ a: { b: ["x", "y"] } }); + }); + + it("overwrites previous value on repeated save", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<string>("key", { storage }); + + store.save("first"); + store.save("second"); + expect(store.load()).toBe("second"); + }); +}); diff --git a/src/adapters/local-storage/index.ts b/src/adapters/local-storage/index.ts new file mode 100644 index 0000000..72135ce --- /dev/null +++ b/src/adapters/local-storage/index.ts @@ -0,0 +1,58 @@ +export interface LocalStore<T> { + load(): T | null; + save(value: T): void; + clear(): void; +} + +export interface CreateLocalStoreOptions { + storage?: Storage | undefined; +} + +function createNoopStore<T>(): LocalStore<T> { + return { + load() { + return null; + }, + save() {}, + clear() {}, + }; +} + +export function createLocalStore<T>(key: string, opts?: CreateLocalStoreOptions): LocalStore<T> { + let storage: Storage | undefined; + if (opts !== undefined && "storage" in opts) { + storage = opts.storage; + } else { + storage = globalThis.localStorage; + } + + if (storage === undefined || storage === null) { + return createNoopStore<T>(); + } + + return { + load(): T | null { + try { + const raw = storage.getItem(key); + if (raw === null) { + return null; + } + return JSON.parse(raw) as T; + } catch { + return null; + } + }, + + save(value: T): void { + try { + storage.setItem(key, JSON.stringify(value)); + } catch { + // Swallow quota / write errors — persistence is best-effort. + } + }, + + clear(): void { + storage.removeItem(key); + }, + }; +} diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 71851de..f1e8e29 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -4,3 +4,4 @@ export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; export { default as ChatView } from "./ui/ChatView.svelte"; export { default as Composer } from "./ui/Composer.svelte"; +export { default as ModelSelector } from "./ui/ModelSelector.svelte"; diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index b7405cf..e997f49 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -28,8 +28,10 @@ export interface ChatStore { readonly chunks: readonly RenderedChunk[]; readonly pendingSync: boolean; readonly error: string | null; + readonly model: string | undefined; handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void; send(text: string): void; + setModel(model: string): void; load(): Promise<void>; dispose(): void; } @@ -38,6 +40,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { let transcript = $state<TranscriptState>(initialState()); let _pendingSync = $state(false); let _error = $state<string | null>(null); + let _model = $state<string | undefined>(deps.model); let disposed = false; async function syncTail(): Promise<void> { @@ -69,12 +72,21 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { get error(): string | null { return _error; }, + get model(): string | undefined { + return _model; + }, handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { if (msg.type === "chat.error") { + if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) { + return; + } _error = msg.message; return; } + if (msg.event.conversationId !== deps.conversationId) { + return; + } transcript = foldEvent(transcript, msg.event); if (transcript.sealedTurnId !== null) { void syncTail(); @@ -86,11 +98,15 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { type: "chat.send", conversationId: deps.conversationId, message: text, - ...(deps.model !== undefined ? { model: deps.model } : {}), + ...(_model !== undefined ? { model: _model } : {}), }; deps.transport.send(msg); }, + setModel(model: string): void { + _model = model; + }, + async load(): Promise<void> { const cached = await deps.cache.load(deps.conversationId); if (cached.length > 0) { diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index 77a53c9..4ec40a9 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -347,4 +347,93 @@ describe("createChatStore", () => { store.dispose(); }); + + it("setModel changes the model used by the next send", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + model: "openai/gpt-4", + transport: transport.impl, + historySync: historySync.impl, + cache: cache.impl, + }); + + store.send("First"); + expect(transport.sent[0]?.model).toBe("openai/gpt-4"); + + store.setModel("anthropic/claude-3"); + store.send("Second"); + expect(transport.sent[1]?.model).toBe("anthropic/claude-3"); + + store.dispose(); + }); + + it("setModel from undefined to a model", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + cache: cache.impl, + }); + + store.send("First"); + expect(transport.sent[0]).not.toHaveProperty("model"); + + store.setModel("openai/gpt-4o"); + store.send("Second"); + expect(transport.sent[1]?.model).toBe("openai/gpt-4o"); + + store.dispose(); + }); + + it("handleDelta ignores a chat.delta for a different conversationId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + cache: cache.impl, + }); + + store.handleDelta( + deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }), + ); + store.handleDelta( + deltaEvent({ + type: "text-delta", + conversationId: "other-conv", + turnId: "t1", + delta: "Should be ignored", + }), + ); + + expect(store.messages).toHaveLength(0); + + store.dispose(); + }); + + it("handleDelta ignores a chat.error for a different conversationId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + cache: cache.impl, + }); + + store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" }); + + expect(store.error).toBeNull(); + + store.dispose(); + }); }); diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts index e58818a..d37b59e 100644 --- a/src/features/chat/test-helpers.ts +++ b/src/features/chat/test-helpers.ts @@ -75,6 +75,9 @@ export function createFakeCache(): FakeCache { async evictIfOverBudget() { return []; }, + async delete(conversationId) { + store.delete(conversationId); + }, }, }; } diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index aebb97c..ac8f640 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import type { RenderedChunk } from "../../core/chunks"; import ChatView from "./ui/ChatView.svelte"; import Composer from "./ui/Composer.svelte"; +import ModelSelector from "./ui/ModelSelector.svelte"; describe("ChatView", () => { it("renders a message's text chunk", () => { @@ -144,8 +145,8 @@ describe("ChatView", () => { render(ChatView, { props: { chunks } }); - const article = screen.getByText("Streaming...").closest("article"); - expect(article).toHaveClass("message--provisional"); + const bubble = screen.getByText("Streaming...").closest(".chat-bubble"); + expect(bubble).toHaveClass("opacity-50"); }); it("renders empty transcript", () => { @@ -260,3 +261,38 @@ describe("Composer", () => { expect(onSend).not.toHaveBeenCalled(); }); }); + +describe("ModelSelector", () => { + it("renders the options and current selection", () => { + const models = ["openai/gpt-4", "anthropic/claude-3", "google/gemini"]; + render(ModelSelector, { + props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() }, + }); + + const select = screen.getByRole("combobox", { name: "Model selector" }); + expect(select).toBeInTheDocument(); + expect(select).toHaveValue("anthropic/claude-3"); + + const options = screen.getAllByRole("option"); + expect(options).toHaveLength(3); + expect(options[0]).toHaveValue("openai/gpt-4"); + expect(options[1]).toHaveValue("anthropic/claude-3"); + expect(options[2]).toHaveValue("google/gemini"); + }); + + it("calls onSelect on change", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4", "anthropic/claude-3"]; + + render(ModelSelector, { + props: { models, selected: "openai/gpt-4", onSelect }, + }); + + const select = screen.getByRole("combobox", { name: "Model selector" }); + await user.selectOptions(select, "anthropic/claude-3"); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3"); + }); +}); diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index ce66798..cb6069b 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -4,14 +4,16 @@ let { chunks }: { chunks: readonly RenderedChunk[] } = $props(); </script> -<div class="chat-transcript" role="log" aria-live="polite"> +<div class="flex flex-col gap-2 p-4" role="log" aria-live="polite"> {#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)} - <article - class="message message--{rendered.role}" - class:message--provisional={rendered.provisional} - > - <header class="message__role">{rendered.role}</header> - <div class="message__content"> + <div class="chat {rendered.role === 'user' ? 'chat-start' : 'chat-end'}"> + <div class="chat-header text-xs opacity-70">{rendered.role}</div> + <div + class="chat-bubble" + class:chat-bubble-primary={rendered.role === "user"} + class:chat-bubble-secondary={rendered.role === "assistant"} + class:opacity-50={rendered.provisional} + > {#if rendered.chunk.type === "text"} <p>{rendered.chunk.text}</p> {:else if rendered.chunk.type === "thinking"} @@ -20,26 +22,26 @@ <p>{rendered.chunk.text}</p> </details> {:else if rendered.chunk.type === "tool-call"} - <div class="tool-call"> + <div class="text-sm"> <strong>{rendered.chunk.toolName}</strong> - <pre>{JSON.stringify(rendered.chunk.input, null, 2)}</pre> + <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre> </div> {:else if rendered.chunk.type === "tool-result"} - <div class="tool-result" class:tool-result--error={rendered.chunk.isError}> + <div class="text-sm" class:text-error={rendered.chunk.isError}> <strong>{rendered.chunk.toolName}</strong> - <pre>{rendered.chunk.content}</pre> + <pre class="text-xs mt-1">{rendered.chunk.content}</pre> </div> {:else if rendered.chunk.type === "error"} - <div class="error" role="alert"> + <div class="text-error" role="alert"> {rendered.chunk.message} {#if rendered.chunk.code} - <span class="error__code">[{rendered.chunk.code}]</span> + <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> {/if} </div> {:else if rendered.chunk.type === "system"} - <div class="system">{rendered.chunk.text}</div> + <div class="text-sm opacity-70">{rendered.chunk.text}</div> {/if} </div> - </article> + </div> {/each} </div> diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index dc71e11..3762340 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -18,16 +18,16 @@ } </script> -<form class="composer" onsubmit={prevent => { prevent.preventDefault(); handleSubmit(); }}> +<form class="flex gap-2 p-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}> <textarea - class="composer__input" + class="textarea textarea-bordered flex-1" bind:value={text} onkeydown={handleKeydown} placeholder="Type a message..." rows="3" aria-label="Message input" ></textarea> - <button class="composer__send" type="submit" disabled={text.trim().length === 0}> + <button class="btn btn-primary" type="submit" disabled={text.trim().length === 0}> Send </button> </form> diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte new file mode 100644 index 0000000..3e25ec3 --- /dev/null +++ b/src/features/chat/ui/ModelSelector.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + let { + models, + selected, + onSelect, + }: { + models: readonly string[]; + selected: string; + onSelect: (model: string) => void; + } = $props(); +</script> + +<select + class="select" + value={selected} + onchange={(e) => onSelect(e.currentTarget.value)} + aria-label="Model selector" +> + {#each models as model (model)} + <option value={model}>{model}</option> + {/each} +</select> diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts index c68ed0d..89e81b8 100644 --- a/src/features/conversation-cache/cache.test.ts +++ b/src/features/conversation-cache/cache.test.ts @@ -171,3 +171,27 @@ describe("cache.evictIfOverBudget", () => { expect(evicted).toEqual([]); }); }); + +describe("cache.delete", () => { + it("removes the conversation from the store", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + await store.append("conv-1", [chunk(1), chunk(2)]); + await cache.delete("conv-1"); + + const stored = await store.load("conv-1"); + expect(stored).toEqual([]); + }); + + it("then load returns []", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]); + await cache.delete("conv-1"); + + const result = await cache.load("conv-1"); + expect(result).toEqual([]); + }); +}); diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts index 4aab487..3d5743a 100644 --- a/src/features/conversation-cache/cache.ts +++ b/src/features/conversation-cache/cache.ts @@ -20,6 +20,9 @@ export interface ConversationCache { * Returns the evicted conversationIds. */ evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>; + + /** Delete all cached data for a single conversation (local forget). */ + delete(conversationId: string): Promise<void>; } export interface ConversationCacheOptions { @@ -67,5 +70,9 @@ export function createConversationCache( } return toEvict; }, + + async delete(conversationId) { + await store.delete(conversationId); + }, }; } diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts new file mode 100644 index 0000000..c01d4ac --- /dev/null +++ b/src/features/tabs/index.ts @@ -0,0 +1,14 @@ +export type { Tab, TabsState } from "./tabs"; +export { + activeTab, + closeTab, + createTab, + deriveTitle, + initialState, + newDraft, + selectTab, + setModel, + setTitle, +} from "./tabs"; +export type { TabsStorage, TabsStore } from "./tabs-store.svelte"; +export { createTabsStore } from "./tabs-store.svelte"; diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts new file mode 100644 index 0000000..cba527e --- /dev/null +++ b/src/features/tabs/tabs-store.svelte.ts @@ -0,0 +1,67 @@ +import type { Tab, TabsState } from "./tabs"; +import { + initialState, + closeTab as reduceCloseTab, + createTab as reduceCreateTab, + newDraft as reduceNewDraft, + selectTab as reduceSelectTab, + setModel as reduceSetModel, + setTitle as reduceSetTitle, + activeTab as selectActiveTab, +} from "./tabs"; + +export interface TabsStorage { + load(): TabsState | null; + save(state: TabsState): void; +} + +export interface TabsStore { + readonly tabs: readonly Tab[]; + readonly activeConversationId: string | null; + readonly activeTab: Tab | null; + newDraft(): void; + createTab(tab: Tab): void; + selectTab(conversationId: string): void; + closeTab(conversationId: string): void; + setModel(conversationId: string, model: string): void; + setTitle(conversationId: string, title: string): void; +} + +export function createTabsStore(storage: TabsStorage): TabsStore { + let state = $state<TabsState>(storage.load() ?? initialState()); + + function apply(next: TabsState): void { + state = next; + storage.save(next); + } + + return { + get tabs(): readonly Tab[] { + return state.tabs; + }, + get activeConversationId(): string | null { + return state.activeConversationId; + }, + get activeTab(): Tab | null { + return selectActiveTab(state); + }, + newDraft(): void { + apply(reduceNewDraft(state)); + }, + createTab(tab: Tab): void { + apply(reduceCreateTab(state, tab)); + }, + selectTab(conversationId: string): void { + apply(reduceSelectTab(state, conversationId)); + }, + closeTab(conversationId: string): void { + apply(reduceCloseTab(state, conversationId)); + }, + setModel(conversationId: string, model: string): void { + apply(reduceSetModel(state, conversationId, model)); + }, + setTitle(conversationId: string, title: string): void { + apply(reduceSetTitle(state, conversationId, title)); + }, + }; +} diff --git a/src/features/tabs/tabs-store.test.ts b/src/features/tabs/tabs-store.test.ts new file mode 100644 index 0000000..81ec8ad --- /dev/null +++ b/src/features/tabs/tabs-store.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import type { TabsState } from "./tabs"; +import type { TabsStorage } from "./tabs-store.svelte"; +import { createTabsStore } from "./tabs-store.svelte"; + +function createMemoryStorage(initial?: TabsState): TabsStorage & { data: TabsState | null } { + let data: TabsState | null = initial ?? null; + return { + get data() { + return data; + }, + set data(v: TabsState | null) { + data = v; + }, + load() { + return data; + }, + save(state: TabsState) { + data = state; + }, + }; +} + +describe("createTabsStore", () => { + it("loads persisted state on construct", () => { + const persisted: TabsState = { + tabs: [{ conversationId: "c1", model: "m1", title: "T1" }], + activeConversationId: "c1", + }; + const storage = createMemoryStorage(persisted); + const store = createTabsStore(storage); + + expect(store.tabs).toHaveLength(1); + expect(store.activeConversationId).toBe("c1"); + expect(store.activeTab?.conversationId).toBe("c1"); + }); + + it("starts with empty draft when no persisted state", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + expect(store.activeTab).toBeNull(); + }); + + it("saves after every mutation", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); + expect(storage.data?.tabs).toHaveLength(1); + expect(storage.data?.activeConversationId).toBe("c1"); + + store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); + expect(storage.data?.tabs).toHaveLength(2); + + store.selectTab("c1"); + expect(storage.data?.activeConversationId).toBe("c1"); + + store.closeTab("c1"); + expect(storage.data?.tabs).toHaveLength(1); + expect(storage.data?.activeConversationId).toBe("c2"); + + store.setModel("c2", "new-model"); + expect(storage.data?.tabs[0]?.model).toBe("new-model"); + + store.setTitle("c2", "New Title"); + expect(storage.data?.tabs[0]?.title).toBe("New Title"); + + store.newDraft(); + expect(storage.data?.activeConversationId).toBeNull(); + }); + + it("createTab appends and activates", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); + expect(store.tabs).toHaveLength(1); + expect(store.activeConversationId).toBe("c1"); + + store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); + expect(store.tabs).toHaveLength(2); + expect(store.activeConversationId).toBe("c2"); + }); + + it("selectTab changes active", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); + + store.selectTab("c1"); + expect(store.activeConversationId).toBe("c1"); + }); + + it("closeTab removes and activates neighbour", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); + store.createTab({ conversationId: "c3", model: "m3", title: "T3" }); + + store.selectTab("c2"); + store.closeTab("c2"); + expect(store.tabs).toHaveLength(2); + expect(store.activeConversationId).toBe("c1"); + }); + + it("closing the last tab returns to draft", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); + store.closeTab("c1"); + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + }); + + it("setModel updates the right tab", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "old", title: "T1" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); + + store.setModel("c1", "new-model"); + expect(store.tabs[0]?.model).toBe("new-model"); + expect(store.tabs[1]?.model).toBe("m2"); + }); + + it("setTitle updates the right tab", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "Old" }); + + store.setTitle("c1", "New Title"); + expect(store.tabs[0]?.title).toBe("New Title"); + }); + + it("newDraft clears active but keeps tabs", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); + + store.newDraft(); + expect(store.tabs).toHaveLength(2); + expect(store.activeConversationId).toBeNull(); + expect(store.activeTab).toBeNull(); + }); +}); diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts new file mode 100644 index 0000000..3034e76 --- /dev/null +++ b/src/features/tabs/tabs.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import type { Tab, TabsState } from "./tabs"; +import { + activeTab, + closeTab, + createTab, + deriveTitle, + initialState, + newDraft, + selectTab, + setModel, + setTitle, +} from "./tabs"; + +const tab = (conversationId: string, model = "default", title = "Chat"): Tab => ({ + conversationId, + model, + title, +}); + +describe("initialState", () => { + it("returns empty draft state when no persisted state", () => { + const state = initialState(); + expect(state.tabs).toEqual([]); + expect(state.activeConversationId).toBeNull(); + }); + + it("returns persisted state when provided", () => { + const persisted: TabsState = { + tabs: [tab("c1")], + activeConversationId: "c1", + }; + const state = initialState(persisted); + expect(state.tabs).toHaveLength(1); + expect(state.activeConversationId).toBe("c1"); + }); +}); + +describe("newDraft", () => { + it("sets activeConversationId to null", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = newDraft(state); + expect(next.activeConversationId).toBeNull(); + }); + + it("keeps existing tabs", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = newDraft(state); + expect(next.tabs).toHaveLength(2); + }); +}); + +describe("createTab", () => { + it("appends and activates", () => { + const state = initialState(); + const next = createTab(state, tab("c1")); + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0]?.conversationId).toBe("c1"); + expect(next.activeConversationId).toBe("c1"); + }); + + it("does not duplicate an existing conversationId", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = createTab(state, tab("c1")); + expect(next.tabs).toHaveLength(1); + }); + + it("activates an already-existing tab when createTab is called again", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; + const next = createTab(state, tab("c1")); + expect(next.activeConversationId).toBe("c1"); + }); +}); + +describe("selectTab", () => { + it("changes active", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = selectTab(state, "c2"); + expect(next.activeConversationId).toBe("c2"); + }); +}); + +describe("closeTab", () => { + it("removes the tab", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = closeTab(state, "c2"); + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0]?.conversationId).toBe("c1"); + }); + + it("closing the active tab activates a neighbour (previous preferred)", () => { + const state: TabsState = { + tabs: [tab("c1"), tab("c2"), tab("c3")], + activeConversationId: "c2", + }; + const next = closeTab(state, "c2"); + expect(next.activeConversationId).toBe("c1"); + }); + + it("closing the first active tab activates the next", () => { + const state: TabsState = { + tabs: [tab("c1"), tab("c2"), tab("c3")], + activeConversationId: "c1", + }; + const next = closeTab(state, "c1"); + expect(next.activeConversationId).toBe("c2"); + }); + + it("closing the last tab returns to draft (null active)", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = closeTab(state, "c1"); + expect(next.tabs).toHaveLength(0); + expect(next.activeConversationId).toBeNull(); + }); + + it("closing a non-active tab does not change active", () => { + const state: TabsState = { + tabs: [tab("c1"), tab("c2"), tab("c3")], + activeConversationId: "c3", + }; + const next = closeTab(state, "c1"); + expect(next.activeConversationId).toBe("c3"); + }); + + it("closing a non-existent tab is a no-op", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = closeTab(state, "missing"); + expect(next).toEqual(state); + }); +}); + +describe("setModel", () => { + it("updates the right tab", () => { + const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" }; + const next = setModel(state, "c1", "new-model"); + expect(next.tabs[0]?.model).toBe("new-model"); + expect(next.tabs[1]?.model).toBe("default"); + }); +}); + +describe("setTitle", () => { + it("updates the right tab", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = setTitle(state, "c1", "Updated title"); + expect(next.tabs[0]?.title).toBe("Updated title"); + expect(next.tabs[1]?.title).toBe("Chat"); + }); +}); + +describe("activeTab", () => { + it("returns the active tab", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; + expect(activeTab(state)?.conversationId).toBe("c2"); + }); + + it("returns null when activeConversationId is null", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: null }; + expect(activeTab(state)).toBeNull(); + }); + + it("returns null when active tab is not found in tabs", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" }; + expect(activeTab(state)).toBeNull(); + }); +}); + +describe("deriveTitle", () => { + it("truncates long messages with ellipsis", () => { + const msg = "This is a very long message that should be truncated at some point"; + expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026"); + }); + + it("returns full message when under max", () => { + expect(deriveTitle("Short", 40)).toBe("Short"); + }); + + it("collapses whitespace", () => { + expect(deriveTitle(" hello world ")).toBe("hello world"); + }); + + it("falls back to 'New chat' for empty input", () => { + expect(deriveTitle("")).toBe("New chat"); + expect(deriveTitle(" ")).toBe("New chat"); + }); + + it("uses default max of ~40 chars", () => { + const msg = "a".repeat(50); + const result = deriveTitle(msg); + expect(result).toBe(`${"a".repeat(40)}\u2026`); + }); +}); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts new file mode 100644 index 0000000..9af522f --- /dev/null +++ b/src/features/tabs/tabs.ts @@ -0,0 +1,74 @@ +export interface Tab { + readonly conversationId: string; + readonly model: string; + readonly title: string; +} + +export interface TabsState { + readonly tabs: readonly Tab[]; + readonly activeConversationId: string | null; +} + +const DEFAULT_TITLE = "New chat"; +const DEFAULT_MAX_TITLE_LENGTH = 40; + +export function initialState(persisted?: TabsState): TabsState { + if (persisted !== undefined) return persisted; + return { tabs: [], activeConversationId: null }; +} + +export function newDraft(state: TabsState): TabsState { + return { ...state, activeConversationId: null }; +} + +export function createTab(state: TabsState, tab: Tab): TabsState { + const exists = state.tabs.some((t) => t.conversationId === tab.conversationId); + const tabs = exists ? state.tabs : [...state.tabs, tab]; + return { tabs, activeConversationId: tab.conversationId }; +} + +export function selectTab(state: TabsState, conversationId: string): TabsState { + return { ...state, activeConversationId: conversationId }; +} + +export function closeTab(state: TabsState, conversationId: string): TabsState { + const idx = state.tabs.findIndex((t) => t.conversationId === conversationId); + if (idx === -1) return state; + + const tabs = state.tabs.filter((t) => t.conversationId !== conversationId); + + if (state.activeConversationId !== conversationId) { + return { tabs, activeConversationId: state.activeConversationId }; + } + + if (tabs.length === 0) { + return { tabs, activeConversationId: null }; + } + + // prefer previous tab, else next + const neighborIdx = idx > 0 ? idx - 1 : 0; + const neighbor = tabs[neighborIdx]; + return { tabs, activeConversationId: neighbor?.conversationId ?? null }; +} + +export function setModel(state: TabsState, conversationId: string, model: string): TabsState { + const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t)); + return { tabs, activeConversationId: state.activeConversationId }; +} + +export function setTitle(state: TabsState, conversationId: string, title: string): TabsState { + const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t)); + return { tabs, activeConversationId: state.activeConversationId }; +} + +export function activeTab(state: TabsState): Tab | null { + if (state.activeConversationId === null) return null; + return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; +} + +export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LENGTH): string { + const trimmed = message.trim().replace(/\s+/g, " "); + if (trimmed.length === 0) return DEFAULT_TITLE; + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max)}\u2026`; +} |
