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/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 ++++++++ 8 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 src/features/chat/ui/ModelSelector.svelte (limited to 'src/features/chat') 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 @@ + + + -- cgit v1.2.3