diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/adapters/idb/index.test.ts | 120 | ||||
| -rw-r--r-- | src/adapters/idb/index.ts | 181 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 6 | ||||
| -rw-r--r-- | src/features/chat/ports.ts | 12 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 106 | ||||
| -rw-r--r-- | src/features/chat/store.test.ts | 350 | ||||
| -rw-r--r-- | src/features/chat/test-helpers.ts | 80 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 228 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 45 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 33 |
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> |
