From 529c6a2bb56447fe93796111df3d4cc5a05fdd93 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 7 Jun 2026 02:06:55 +0900 Subject: 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
keying fix) - features/conversation-cache: surface delete(conversationId) on the wrapper for tab-close local-forget - adapters/local-storage: generic injected JSON localStore (quota/corrupt-safe) Verified: svelte-check 0/0, vitest 273, biome clean, build ok. --- src/adapters/local-storage/index.test.ts | 120 ++++++++++++++++ src/adapters/local-storage/index.ts | 58 ++++++++ src/features/chat/index.ts | 1 + src/features/chat/store.svelte.ts | 18 ++- src/features/chat/store.test.ts | 89 ++++++++++++ src/features/chat/test-helpers.ts | 3 + src/features/chat/ui.test.ts | 40 +++++- src/features/chat/ui/ChatView.svelte | 32 +++-- src/features/chat/ui/Composer.svelte | 6 +- src/features/chat/ui/ModelSelector.svelte | 22 +++ src/features/conversation-cache/cache.test.ts | 24 ++++ src/features/conversation-cache/cache.ts | 7 + src/features/tabs/index.ts | 14 ++ src/features/tabs/tabs-store.svelte.ts | 67 +++++++++ src/features/tabs/tabs-store.test.ts | 157 +++++++++++++++++++++ src/features/tabs/tabs.test.ts | 191 ++++++++++++++++++++++++++ src/features/tabs/tabs.ts | 74 ++++++++++ 17 files changed, 902 insertions(+), 21 deletions(-) create mode 100644 src/adapters/local-storage/index.test.ts create mode 100644 src/adapters/local-storage/index.ts create mode 100644 src/features/chat/ui/ModelSelector.svelte create mode 100644 src/features/tabs/index.ts create mode 100644 src/features/tabs/tabs-store.svelte.ts create mode 100644 src/features/tabs/tabs-store.test.ts create mode 100644 src/features/tabs/tabs.test.ts create mode 100644 src/features/tabs/tabs.ts 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(); + 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("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("corrupt", { storage }); + + expect(store.load()).toBeNull(); + }); + + it("clear removes the value", () => { + const storage = createMemoryStorage(); + const store = createLocalStore("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("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("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("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("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 { + load(): T | null; + save(value: T): void; + clear(): void; +} + +export interface CreateLocalStoreOptions { + storage?: Storage | undefined; +} + +function createNoopStore(): LocalStore { + return { + load() { + return null; + }, + save() {}, + clear() {}, + }; +} + +export function createLocalStore(key: string, opts?: CreateLocalStoreOptions): LocalStore { + let storage: Storage | undefined; + if (opts !== undefined && "storage" in opts) { + storage = opts.storage; + } else { + storage = globalThis.localStorage; + } + + if (storage === undefined || storage === null) { + return createNoopStore(); + } + + 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; dispose(): void; } @@ -38,6 +40,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { let transcript = $state(initialState()); let _pendingSync = $state(false); let _error = $state(null); + let _model = $state(deps.model); let disposed = false; async function syncTail(): Promise { @@ -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 { 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(); -
+
{#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)} -
-
{rendered.role}
-
+
+
{rendered.role}
+
{#if rendered.chunk.type === "text"}

{rendered.chunk.text}

{:else if rendered.chunk.type === "thinking"} @@ -20,26 +22,26 @@

{rendered.chunk.text}

{:else if rendered.chunk.type === "tool-call"} -
+
{rendered.chunk.toolName} -
{JSON.stringify(rendered.chunk.input, null, 2)}
+
{JSON.stringify(rendered.chunk.input, null, 2)}
{:else if rendered.chunk.type === "tool-result"} -
+
{rendered.chunk.toolName} -
{rendered.chunk.content}
+
{rendered.chunk.content}
{:else if rendered.chunk.type === "error"} - -
+
{/each}
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 @@ } -
{ prevent.preventDefault(); handleSubmit(); }}> + { e.preventDefault(); handleSubmit(); }}> -
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 @@ + + + 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; + + /** Delete all cached data for a single conversation (local forget). */ + delete(conversationId: string): Promise; } 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(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`; +} -- cgit v1.2.3