summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 02:06:55 +0900
committerAdam Malczewski <[email protected]>2026-06-07 02:06:55 +0900
commit529c6a2bb56447fe93796111df3d4cc5a05fdd93 (patch)
tree8db14b4b072b8a73ac85963f625b5bb3f77883ac /src/features/chat
parent90c438c4562793eb09358f9d1a050d2267f4fca5 (diff)
downloaddispatch-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.ts1
-rw-r--r--src/features/chat/store.svelte.ts18
-rw-r--r--src/features/chat/store.test.ts89
-rw-r--r--src/features/chat/test-helpers.ts3
-rw-r--r--src/features/chat/ui.test.ts40
-rw-r--r--src/features/chat/ui/ChatView.svelte32
-rw-r--r--src/features/chat/ui/Composer.svelte6
-rw-r--r--src/features/chat/ui/ModelSelector.svelte22
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>