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 /src/features/chat | |
| 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.
Diffstat (limited to 'src/features/chat')
| -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 |
8 files changed, 190 insertions, 21 deletions
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> |
