summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/adapters/idb/index.test.ts120
-rw-r--r--src/adapters/idb/index.ts181
-rw-r--r--src/features/chat/index.ts6
-rw-r--r--src/features/chat/ports.ts12
-rw-r--r--src/features/chat/store.svelte.ts106
-rw-r--r--src/features/chat/store.test.ts350
-rw-r--r--src/features/chat/test-helpers.ts80
-rw-r--r--src/features/chat/ui.test.ts228
-rw-r--r--src/features/chat/ui/ChatView.svelte45
-rw-r--r--src/features/chat/ui/Composer.svelte33
10 files changed, 1161 insertions, 0 deletions
diff --git a/src/adapters/idb/index.test.ts b/src/adapters/idb/index.test.ts
new file mode 100644
index 0000000..12bb5ad
--- /dev/null
+++ b/src/adapters/idb/index.test.ts
@@ -0,0 +1,120 @@
+import "fake-indexeddb/auto";
+import type { StoredChunk } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { createIdbChunkStore } from "./index";
+
+function textChunk(text: string): StoredChunk["chunk"] {
+ return { type: "text", text };
+}
+
+function makeChunk(
+ seq: number,
+ text: string,
+ role: StoredChunk["role"] = "assistant",
+): StoredChunk {
+ return { seq, role, chunk: textChunk(text) };
+}
+
+describe("createIdbChunkStore", () => {
+ it("append then load returns chunks seq-ordered", async () => {
+ const store = createIdbChunkStore({ indexedDB: new IDBFactory() });
+ const chunks = [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")];
+
+ await store.append("conv1", chunks);
+ const loaded = await store.load("conv1");
+
+ expect(loaded).toHaveLength(3);
+ expect(loaded[0]?.seq).toBe(1);
+ expect(loaded[1]?.seq).toBe(2);
+ expect(loaded[2]?.seq).toBe(3);
+ expect(loaded[0]?.chunk).toEqual(textChunk("a"));
+ });
+
+ it("append out-of-order still loads seq-ordered", async () => {
+ const store = createIdbChunkStore({ indexedDB: new IDBFactory() });
+ const chunks = [makeChunk(3, "c"), makeChunk(1, "a"), makeChunk(2, "b")];
+
+ await store.append("conv1", chunks);
+ const loaded = await store.load("conv1");
+
+ expect(loaded).toHaveLength(3);
+ expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]);
+ });
+
+ it("append is idempotent on duplicate seq", async () => {
+ const store = createIdbChunkStore({ indexedDB: new IDBFactory() });
+
+ await store.append("conv1", [makeChunk(1, "first"), makeChunk(2, "b")]);
+ await store.append("conv1", [makeChunk(1, "first"), makeChunk(3, "c")]);
+
+ const loaded = await store.load("conv1");
+ expect(loaded).toHaveLength(3);
+ expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]);
+ expect(loaded[0]?.chunk).toEqual(textChunk("first"));
+ });
+
+ it("load returns [] for an absent conversation", async () => {
+ const store = createIdbChunkStore({ indexedDB: new IDBFactory() });
+
+ const loaded = await store.load("nonexistent");
+ expect(loaded).toEqual([]);
+ });
+
+ it("delete removes a conversation", async () => {
+ const store = createIdbChunkStore({ indexedDB: new IDBFactory() });
+
+ await store.append("conv1", [makeChunk(1, "a")]);
+ await store.append("conv2", [makeChunk(1, "b")]);
+
+ await store.delete("conv1");
+
+ expect(await store.load("conv1")).toEqual([]);
+ const conv2 = await store.load("conv2");
+ expect(conv2).toHaveLength(1);
+ expect(conv2[0]?.chunk).toEqual(textChunk("b"));
+ });
+
+ it("index aggregates chunkCount and maxSeq", async () => {
+ const store = createIdbChunkStore({ indexedDB: new IDBFactory() });
+
+ await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]);
+ await store.append("conv2", [makeChunk(1, "x")]);
+
+ const idx = await store.index();
+ expect(idx).toHaveLength(2);
+
+ const c1 = idx.find((e) => e.conversationId === "conv1");
+ const c2 = idx.find((e) => e.conversationId === "conv2");
+
+ expect(c1?.chunkCount).toBe(3);
+ expect(c1?.maxSeq).toBe(3);
+ expect(c2?.chunkCount).toBe(1);
+ expect(c2?.maxSeq).toBe(1);
+ });
+
+ it("index reports lastAccess after load", async () => {
+ const store = createIdbChunkStore({ indexedDB: new IDBFactory() });
+
+ await store.append("conv1", [makeChunk(1, "a")]);
+ const idx = await store.index();
+
+ const entry = idx.find((e) => e.conversationId === "conv1");
+ expect(entry?.lastAccess).toBeTypeOf("number");
+ expect(entry?.lastAccess).toBeGreaterThan(0);
+ });
+
+ it("separate conversations are isolated", async () => {
+ const store = createIdbChunkStore({ indexedDB: new IDBFactory() });
+
+ await store.append("conv1", [makeChunk(1, "a1"), makeChunk(2, "a2")]);
+ await store.append("conv2", [makeChunk(1, "b1")]);
+
+ const loaded1 = await store.load("conv1");
+ const loaded2 = await store.load("conv2");
+
+ expect(loaded1).toHaveLength(2);
+ expect(loaded2).toHaveLength(1);
+ expect(loaded1[0]?.chunk).toEqual(textChunk("a1"));
+ expect(loaded2[0]?.chunk).toEqual(textChunk("b1"));
+ });
+});
diff --git a/src/adapters/idb/index.ts b/src/adapters/idb/index.ts
new file mode 100644
index 0000000..302edb5
--- /dev/null
+++ b/src/adapters/idb/index.ts
@@ -0,0 +1,181 @@
+import type { StoredChunk } from "@dispatch/wire";
+import type {
+ ConversationCacheIndexEntry,
+ ConversationChunkStore,
+} from "../../features/conversation-cache";
+
+const DEFAULT_DB_NAME = "dispatch-chunk-cache";
+const DB_VERSION = 1;
+const CHUNKS_STORE = "chunks";
+const META_STORE = "meta";
+
+interface ChunkRecord {
+ conversationId: string;
+ seq: number;
+ role: StoredChunk["role"];
+ chunk: StoredChunk["chunk"];
+}
+
+interface MetaRecord {
+ conversationId: string;
+ lastAccess: number;
+}
+
+export interface CreateIdbChunkStoreOptions {
+ indexedDB?: IDBFactory;
+ dbName?: string;
+}
+
+function requestToPromise<T>(req: IDBRequest<T>): Promise<T> {
+ return new Promise<T>((resolve, reject) => {
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function txComplete(tx: IDBTransaction): Promise<void> {
+ return new Promise<void>((resolve, reject) => {
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ tx.onabort = () => reject(tx.error);
+ });
+}
+
+function openDb(idb: IDBFactory, dbName: string): Promise<IDBDatabase> {
+ return new Promise<IDBDatabase>((resolve, reject) => {
+ const req = idb.open(dbName, DB_VERSION);
+
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(CHUNKS_STORE)) {
+ const store = db.createObjectStore(CHUNKS_STORE, {
+ keyPath: ["conversationId", "seq"],
+ });
+ store.createIndex("byConversation", "conversationId");
+ }
+ if (!db.objectStoreNames.contains(META_STORE)) {
+ db.createObjectStore(META_STORE, { keyPath: "conversationId" });
+ }
+ };
+
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function keyRangeFor(conversationId: string): IDBKeyRange {
+ const lower: [string, number] = [conversationId, 0];
+ const upper: [string, number] = [conversationId, Number.POSITIVE_INFINITY];
+ return IDBKeyRange.bound(lower, upper);
+}
+
+function chunksToStoredChunks(records: ChunkRecord[]): StoredChunk[] {
+ return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk }));
+}
+
+export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): ConversationChunkStore {
+ const idb = opts?.indexedDB ?? globalThis.indexedDB;
+ const dbName = opts?.dbName ?? DEFAULT_DB_NAME;
+
+ let dbPromise: Promise<IDBDatabase> | null = null;
+
+ function getDb(): Promise<IDBDatabase> {
+ if (dbPromise === null) {
+ dbPromise = openDb(idb, dbName);
+ }
+ return dbPromise;
+ }
+
+ return {
+ async load(conversationId: string): Promise<readonly StoredChunk[]> {
+ const db = await getDb();
+ const tx = db.transaction(CHUNKS_STORE, "readonly");
+ const store = tx.objectStore(CHUNKS_STORE);
+ const range = keyRangeFor(conversationId);
+ const records = await requestToPromise<ChunkRecord[]>(store.getAll(range));
+ await txComplete(tx);
+
+ records.sort((a, b) => a.seq - b.seq);
+ return chunksToStoredChunks(records);
+ },
+
+ async append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void> {
+ if (chunks.length === 0) return;
+
+ const db = await getDb();
+ const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite");
+ const chunkStore = tx.objectStore(CHUNKS_STORE);
+ const metaStore = tx.objectStore(META_STORE);
+
+ for (const c of chunks) {
+ chunkStore.put({
+ conversationId,
+ seq: c.seq,
+ role: c.role,
+ chunk: c.chunk,
+ } satisfies ChunkRecord);
+ }
+
+ metaStore.put({
+ conversationId,
+ lastAccess: Date.now(),
+ } satisfies MetaRecord);
+
+ await txComplete(tx);
+ },
+
+ async delete(conversationId: string): Promise<void> {
+ const db = await getDb();
+ const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite");
+ const chunkStore = tx.objectStore(CHUNKS_STORE);
+ const metaStore = tx.objectStore(META_STORE);
+
+ chunkStore.delete(keyRangeFor(conversationId));
+ metaStore.delete(conversationId);
+
+ await txComplete(tx);
+ },
+
+ async index(): Promise<readonly ConversationCacheIndexEntry[]> {
+ const db = await getDb();
+ const tx = db.transaction([CHUNKS_STORE, META_STORE], "readonly");
+ const chunkStore = tx.objectStore(CHUNKS_STORE);
+ const metaStore = tx.objectStore(META_STORE);
+
+ const allChunks = await requestToPromise<ChunkRecord[]>(chunkStore.getAll());
+ const allMeta = await requestToPromise<MetaRecord[]>(metaStore.getAll());
+ await txComplete(tx);
+
+ const metaMap = new Map<string, number>();
+ for (const m of allMeta) {
+ metaMap.set(m.conversationId, m.lastAccess);
+ }
+
+ const grouped = new Map<string, { chunkCount: number; maxSeq: number }>();
+ for (const r of allChunks) {
+ const existing = grouped.get(r.conversationId);
+ if (existing === undefined) {
+ grouped.set(r.conversationId, { chunkCount: 1, maxSeq: r.seq });
+ } else {
+ existing.chunkCount++;
+ if (r.seq > existing.maxSeq) {
+ existing.maxSeq = r.seq;
+ }
+ }
+ }
+
+ const result: ConversationCacheIndexEntry[] = [];
+ for (const [conversationId, stats] of grouped) {
+ const lastAccess = metaMap.get(conversationId);
+ result.push({
+ conversationId,
+ chunkCount: stats.chunkCount,
+ maxSeq: stats.maxSeq,
+ ...(lastAccess !== undefined ? { lastAccess } : {}),
+ });
+ }
+
+ return result;
+ },
+ };
+}
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
new file mode 100644
index 0000000..71851de
--- /dev/null
+++ b/src/features/chat/index.ts
@@ -0,0 +1,6 @@
+export type { RenderedChunk } from "../../core/chunks";
+export type { ChatTransport, HistorySync } from "./ports";
+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";
diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts
new file mode 100644
index 0000000..07943c7
--- /dev/null
+++ b/src/features/chat/ports.ts
@@ -0,0 +1,12 @@
+import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract";
+
+/** Injected transport port — sends chat messages to the server. */
+export interface ChatTransport {
+ send(msg: ChatSendMessage): void;
+}
+
+/** Injected history-sync port — fetches incremental history from the server. */
+export type HistorySync = (
+ conversationId: string,
+ sinceSeq: number,
+) => Promise<ConversationHistoryResponse>;
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
new file mode 100644
index 0000000..b7405cf
--- /dev/null
+++ b/src/features/chat/store.svelte.ts
@@ -0,0 +1,106 @@
+import type {
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ChatSendMessage,
+} from "@dispatch/transport-contract";
+import type { ChatMessage } from "@dispatch/wire";
+import type { RenderedChunk, TranscriptState } from "../../core/chunks";
+import {
+ applyHistory,
+ foldEvent,
+ initialState,
+ selectChunks,
+ selectMessages,
+} from "../../core/chunks";
+import type { ConversationCache } from "../conversation-cache";
+import type { ChatTransport, HistorySync } from "./ports";
+
+export interface ChatStoreDependencies {
+ readonly conversationId: string;
+ readonly model?: string;
+ readonly transport: ChatTransport;
+ readonly historySync: HistorySync;
+ readonly cache: ConversationCache;
+}
+
+export interface ChatStore {
+ readonly messages: readonly ChatMessage[];
+ readonly chunks: readonly RenderedChunk[];
+ readonly pendingSync: boolean;
+ readonly error: string | null;
+ handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
+ send(text: string): void;
+ load(): Promise<void>;
+ dispose(): void;
+}
+
+export function createChatStore(deps: ChatStoreDependencies): ChatStore {
+ let transcript = $state<TranscriptState>(initialState());
+ let _pendingSync = $state(false);
+ let _error = $state<string | null>(null);
+ let disposed = false;
+
+ async function syncTail(): Promise<void> {
+ if (disposed || _pendingSync) return;
+ _pendingSync = true;
+ try {
+ const since = await deps.cache.sinceSeq(deps.conversationId);
+ const res = await deps.historySync(deps.conversationId, since);
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ transcript = applyHistory(transcript, merged);
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ } finally {
+ _pendingSync = false;
+ }
+ }
+
+ return {
+ get messages(): readonly ChatMessage[] {
+ return selectMessages(transcript);
+ },
+ get chunks(): readonly RenderedChunk[] {
+ return selectChunks(transcript);
+ },
+ get pendingSync(): boolean {
+ return _pendingSync;
+ },
+ get error(): string | null {
+ return _error;
+ },
+
+ handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
+ if (msg.type === "chat.error") {
+ _error = msg.message;
+ return;
+ }
+ transcript = foldEvent(transcript, msg.event);
+ if (transcript.sealedTurnId !== null) {
+ void syncTail();
+ }
+ },
+
+ send(text: string): void {
+ const msg: ChatSendMessage = {
+ type: "chat.send",
+ conversationId: deps.conversationId,
+ message: text,
+ ...(deps.model !== undefined ? { model: deps.model } : {}),
+ };
+ deps.transport.send(msg);
+ },
+
+ async load(): Promise<void> {
+ const cached = await deps.cache.load(deps.conversationId);
+ if (cached.length > 0) {
+ transcript = applyHistory(transcript, cached);
+ }
+ await syncTail();
+ },
+
+ dispose(): void {
+ disposed = true;
+ },
+ };
+}
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
new file mode 100644
index 0000000..77a53c9
--- /dev/null
+++ b/src/features/chat/store.test.ts
@@ -0,0 +1,350 @@
+import type { AgentEvent, StoredChunk } from "@dispatch/wire";
+import { describe, expect, it, vi } from "vitest";
+import { createChatStore } from "./store.svelte";
+import { createFakeCache, createFakeHistorySync, createFakeTransport } from "./test-helpers";
+
+const CONV_ID = "test-conv-1";
+
+function makeStoredChunk(seq: number, role: "user" | "assistant" = "assistant"): StoredChunk {
+ return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } };
+}
+
+function deltaEvent(event: AgentEvent): import("@dispatch/transport-contract").ChatDeltaMessage {
+ return { type: "chat.delta", event };
+}
+
+function errorMessage(message: string): import("@dispatch/transport-contract").ChatErrorMessage {
+ return { type: "chat.error", message };
+}
+
+describe("createChatStore", () => {
+ it("folding a chat.delta updates messages", () => {
+ 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: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }),
+ );
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }),
+ );
+
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("assistant");
+ expect(store.messages[0]?.chunks).toHaveLength(1);
+ expect(store.messages[0]?.chunks[0]?.type).toBe("text");
+ expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe(
+ "Hello world",
+ );
+
+ store.dispose();
+ });
+
+ it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ cache: cache.impl,
+ });
+
+ // Set up what the history sync will return
+ historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }),
+ );
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait for the async sync to complete
+ await vi.waitFor(() => {
+ expect(historySync.calls).toHaveLength(1);
+ });
+
+ expect(historySync.calls[0]?.conversationId).toBe(CONV_ID);
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+
+ // Cache should have the committed chunks
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(2);
+
+ // Messages should include both provisional and committed
+ expect(store.messages.length).toBeGreaterThanOrEqual(1);
+
+ store.dispose();
+ });
+
+ it("send posts a chat.send with 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.send("Hello server");
+
+ expect(transport.sent).toHaveLength(1);
+ expect(transport.sent[0]?.type).toBe("chat.send");
+ expect(transport.sent[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sent[0]?.message).toBe("Hello server");
+ expect(transport.sent[0]).not.toHaveProperty("model");
+
+ store.dispose();
+ });
+
+ it("send posts a chat.send with model when set", () => {
+ 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("Hello");
+
+ expect(transport.sent).toHaveLength(1);
+ expect(transport.sent[0]?.model).toBe("openai/gpt-4");
+
+ store.dispose();
+ });
+
+ it("chat.error sets error", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.error).toBeNull();
+
+ store.handleDelta(errorMessage("Something broke"));
+
+ expect(store.error).toBe("Something broke");
+
+ store.dispose();
+ });
+
+ it("load hydrates from cache then syncs the tail", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const cache = createFakeCache();
+
+ // Pre-populate cache
+ await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]);
+
+ // History sync returns new chunks
+ historySync.returnChunks = [makeStoredChunk(3, "assistant")];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ // Should have synced
+ expect(historySync.calls).toHaveLength(1);
+ expect(historySync.calls[0]?.sinceSeq).toBe(2);
+
+ // Messages should include all chunks
+ expect(store.messages.length).toBeGreaterThanOrEqual(2);
+
+ store.dispose();
+ });
+
+ it("load with empty cache still syncs", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const cache = createFakeCache();
+
+ historySync.returnChunks = [makeStoredChunk(1, "assistant")];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ expect(historySync.calls).toHaveLength(1);
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+
+ store.dispose();
+ });
+
+ it("error is cleared on successful sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ cache: cache.impl,
+ });
+
+ // First, set an error
+ store.handleDelta(errorMessage("fail"));
+ expect(store.error).toBe("fail");
+
+ // Now trigger a successful sync via turn-sealed
+ historySync.returnChunks = [makeStoredChunk(1)];
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(store.error).toBeNull();
+ });
+
+ store.dispose();
+ });
+
+ it("dispose prevents further syncs", async () => {
+ 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.dispose();
+
+ // Trigger a turn-sealed after dispose
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait a tick to let any async work settle
+ await new Promise((r) => setTimeout(r, 10));
+
+ // No sync should have happened
+ expect(historySync.calls).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("overlapping syncs are guarded", async () => {
+ const transport = createFakeTransport();
+ const _historySync = createFakeHistorySync();
+ const cache = createFakeCache();
+
+ // Make the first sync slow
+ let resolveFirstSync: (() => void) | undefined;
+ const firstSyncPromise = new Promise<void>((resolve) => {
+ resolveFirstSync = resolve;
+ });
+
+ let callCount = 0;
+ const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => {
+ callCount++;
+ if (callCount === 1) {
+ await firstSyncPromise;
+ }
+ return { chunks: [], latestSeq: sinceSeq };
+ };
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: slowHistorySync,
+ cache: cache.impl,
+ });
+
+ // Trigger first sync
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait a tick so the first sync starts
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Trigger second sync while first is pending
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" }));
+
+ // Only one call should have been made (second was guarded)
+ expect(callCount).toBe(1);
+
+ // Release the first sync
+ resolveFirstSync?.();
+ await new Promise((r) => setTimeout(r, 10));
+
+ store.dispose();
+ });
+
+ it("handles tool-call and tool-result chunks", () => {
+ 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: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp/test.txt" },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-result",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents",
+ isError: false,
+ }),
+ );
+
+ expect(store.chunks).toHaveLength(2);
+ expect(store.chunks[0]?.chunk.type).toBe("tool-call");
+ expect(store.chunks[1]?.chunk.type).toBe("tool-result");
+
+ store.dispose();
+ });
+});
diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts
new file mode 100644
index 0000000..e58818a
--- /dev/null
+++ b/src/features/chat/test-helpers.ts
@@ -0,0 +1,80 @@
+import type { StoredChunk } from "@dispatch/wire";
+import type { ConversationCache } from "../conversation-cache";
+import type { ChatTransport, HistorySync } from "./ports";
+
+export interface FakeTransport {
+ readonly sent: import("@dispatch/transport-contract").ChatSendMessage[];
+ readonly impl: ChatTransport;
+}
+
+export function createFakeTransport(): FakeTransport {
+ const sent: import("@dispatch/transport-contract").ChatSendMessage[] = [];
+ return {
+ sent,
+ impl: {
+ send(msg) {
+ sent.push(msg);
+ },
+ },
+ };
+}
+
+export interface FakeHistorySync {
+ readonly calls: Array<{ conversationId: string; sinceSeq: number }>;
+ /** Set the chunks to return on the next call. */
+ returnChunks: readonly StoredChunk[];
+ readonly impl: HistorySync;
+}
+
+export function createFakeHistorySync(): FakeHistorySync {
+ const calls: Array<{ conversationId: string; sinceSeq: number }> = [];
+ let returnChunks: readonly StoredChunk[] = [];
+ return {
+ calls,
+ get returnChunks() {
+ return returnChunks;
+ },
+ set returnChunks(v: readonly StoredChunk[]) {
+ returnChunks = v;
+ },
+ impl: async (conversationId, sinceSeq) => {
+ calls.push({ conversationId, sinceSeq });
+ const chunks = returnChunks;
+ const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
+ return { chunks, latestSeq };
+ },
+ };
+}
+
+export interface FakeCache {
+ readonly store: Map<string, StoredChunk[]>;
+ readonly impl: ConversationCache;
+}
+
+export function createFakeCache(): FakeCache {
+ const store = new Map<string, StoredChunk[]>();
+ return {
+ store,
+ impl: {
+ async load(conversationId) {
+ return store.get(conversationId) ?? [];
+ },
+ async commit(conversationId, incoming) {
+ const existing = store.get(conversationId) ?? [];
+ const seen = new Set(existing.map((c) => c.seq));
+ const toAppend = incoming.filter((c) => !seen.has(c.seq));
+ const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq);
+ store.set(conversationId, merged);
+ return merged;
+ },
+ async sinceSeq(conversationId) {
+ const chunks = store.get(conversationId) ?? [];
+ if (chunks.length === 0) return 0;
+ return Math.max(...chunks.map((c) => c.seq));
+ },
+ async evictIfOverBudget() {
+ return [];
+ },
+ },
+ };
+}
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
new file mode 100644
index 0000000..c4793a0
--- /dev/null
+++ b/src/features/chat/ui.test.ts
@@ -0,0 +1,228 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+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";
+
+describe("ChatView", () => {
+ it("renders a message's text chunk", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello world" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Hello world")).toBeInTheDocument();
+ expect(screen.getByText("assistant")).toBeInTheDocument();
+ });
+
+ it("renders multiple chunks", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Hi there")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ });
+
+ it("renders tool-call chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp/test.txt" },
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ const pre = screen.getByText((content, element) => {
+ return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
+ });
+ expect(pre).toBeInTheDocument();
+ });
+
+ it("renders tool-result chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents here",
+ isError: false,
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("file contents here")).toBeInTheDocument();
+ });
+
+ it("renders error chunks with alert role", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "error", message: "Something failed" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("Something failed");
+ });
+
+ it("renders error chunks with code", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Rate limited")).toBeInTheDocument();
+ expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument();
+ });
+
+ it("renders system chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "system",
+ chunk: { type: "system", text: "System context loaded" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("System context loaded")).toBeInTheDocument();
+ });
+
+ it("marks provisional chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "text", text: "Streaming..." },
+ provisional: true,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ const article = screen.getByText("Streaming...").closest("article");
+ expect(article).toHaveClass("message--provisional");
+ });
+
+ it("renders empty transcript", () => {
+ render(ChatView, { props: { chunks: [] } });
+
+ const log = screen.getByRole("log");
+ expect(log).toBeInTheDocument();
+ expect(log.children).toHaveLength(0);
+ });
+});
+
+describe("Composer", () => {
+ it("calls onSend with the typed text and clears", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Hello world");
+
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
+
+ expect(onSend).toHaveBeenCalledTimes(1);
+ expect(onSend).toHaveBeenCalledWith("Hello world");
+ expect(textarea).toHaveValue("");
+ });
+
+ it("does not call onSend with empty text", async () => {
+ const onSend = vi.fn();
+ const _user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ expect(sendButton).toBeDisabled();
+
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("trims whitespace before sending", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, " hello ");
+
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
+
+ expect(onSend).toHaveBeenCalledWith("hello");
+ });
+
+ it("sends on Enter key (without Shift)", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Test message{Enter}");
+
+ expect(onSend).toHaveBeenCalledWith("Test message");
+ });
+
+ it("does not send on Shift+Enter", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
+
+ expect(onSend).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
new file mode 100644
index 0000000..a7c39cc
--- /dev/null
+++ b/src/features/chat/ui/ChatView.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ import type { RenderedChunk } from "../index";
+
+ let { chunks }: { chunks: readonly RenderedChunk[] } = $props();
+</script>
+
+<div class="chat-transcript" role="log" aria-live="polite">
+ {#each chunks as rendered (rendered)}
+ <article
+ class="message message--{rendered.role}"
+ class:message--provisional={rendered.provisional}
+ >
+ <header class="message__role">{rendered.role}</header>
+ <div class="message__content">
+ {#if rendered.chunk.type === "text"}
+ <p>{rendered.chunk.text}</p>
+ {:else if rendered.chunk.type === "thinking"}
+ <details>
+ <summary>Thinking</summary>
+ <p>{rendered.chunk.text}</p>
+ </details>
+ {:else if rendered.chunk.type === "tool-call"}
+ <div class="tool-call">
+ <strong>{rendered.chunk.toolName}</strong>
+ <pre>{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}>
+ <strong>{rendered.chunk.toolName}</strong>
+ <pre>{rendered.chunk.content}</pre>
+ </div>
+ {:else if rendered.chunk.type === "error"}
+ <div class="error" role="alert">
+ {rendered.chunk.message}
+ {#if rendered.chunk.code}
+ <span class="error__code">[{rendered.chunk.code}]</span>
+ {/if}
+ </div>
+ {:else if rendered.chunk.type === "system"}
+ <div class="system">{rendered.chunk.text}</div>
+ {/if}
+ </div>
+ </article>
+ {/each}
+</div>
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
new file mode 100644
index 0000000..dc71e11
--- /dev/null
+++ b/src/features/chat/ui/Composer.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+ let { onSend }: { onSend: (text: string) => void } = $props();
+
+ let text = $state("");
+
+ function handleSubmit(): void {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return;
+ onSend(trimmed);
+ text = "";
+ }
+
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
+</script>
+
+<form class="composer" onsubmit={prevent => { prevent.preventDefault(); handleSubmit(); }}>
+ <textarea
+ class="composer__input"
+ 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}>
+ Send
+ </button>
+</form>