summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/adapters/local-storage/index.test.ts120
-rw-r--r--src/adapters/local-storage/index.ts58
-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
-rw-r--r--src/features/conversation-cache/cache.test.ts24
-rw-r--r--src/features/conversation-cache/cache.ts7
-rw-r--r--src/features/tabs/index.ts14
-rw-r--r--src/features/tabs/tabs-store.svelte.ts67
-rw-r--r--src/features/tabs/tabs-store.test.ts157
-rw-r--r--src/features/tabs/tabs.test.ts191
-rw-r--r--src/features/tabs/tabs.ts74
17 files changed, 902 insertions, 21 deletions
diff --git a/src/adapters/local-storage/index.test.ts b/src/adapters/local-storage/index.test.ts
new file mode 100644
index 0000000..57103dd
--- /dev/null
+++ b/src/adapters/local-storage/index.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vitest";
+import { createLocalStore } from "./index";
+
+function createMemoryStorage(): Storage {
+ const map = new Map<string, string>();
+ return {
+ get length() {
+ return map.size;
+ },
+ clear() {
+ map.clear();
+ },
+ getItem(key: string) {
+ return map.get(key) ?? null;
+ },
+ key(index: number) {
+ return [...map.keys()][index] ?? null;
+ },
+ removeItem(key: string) {
+ map.delete(key);
+ },
+ setItem(key: string, value: string) {
+ map.set(key, value);
+ },
+ };
+}
+
+describe("createLocalStore", () => {
+ it("save then load round-trips an object", () => {
+ const storage = createMemoryStorage();
+ const store = createLocalStore<{ name: string; count: number }>("test", { storage });
+
+ store.save({ name: "alice", count: 42 });
+ const loaded = store.load();
+
+ expect(loaded).toEqual({ name: "alice", count: 42 });
+ });
+
+ it("load returns null when key is absent", () => {
+ const storage = createMemoryStorage();
+ const store = createLocalStore<string>("missing", { storage });
+
+ expect(store.load()).toBeNull();
+ });
+
+ it("load returns null on corrupt JSON", () => {
+ const storage = createMemoryStorage();
+ storage.setItem("corrupt", "{not valid json!!!");
+ const store = createLocalStore<object>("corrupt", { storage });
+
+ expect(store.load()).toBeNull();
+ });
+
+ it("clear removes the value", () => {
+ const storage = createMemoryStorage();
+ const store = createLocalStore<string>("key", { storage });
+
+ store.save("hello");
+ expect(store.load()).toBe("hello");
+
+ store.clear();
+ expect(store.load()).toBeNull();
+ });
+
+ it("save swallows a throwing setItem (quota) without throwing", () => {
+ const storage = createMemoryStorage();
+ const originalSetItem = storage.setItem.bind(storage);
+ let callCount = 0;
+ storage.setItem = (_key: string, _value: string) => {
+ callCount++;
+ if (callCount > 1) {
+ throw new DOMException("QuotaExceededError", "QuotaExceededError");
+ }
+ originalSetItem(_key, _value);
+ };
+
+ const store = createLocalStore<number[]>("quota", { storage });
+
+ // First save works
+ store.save([1, 2, 3]);
+ expect(store.load()).toEqual([1, 2, 3]);
+
+ // Second save throws but is swallowed
+ expect(() => store.save([4, 5, 6])).not.toThrow();
+ });
+
+ it("construction with undefined storage yields a safe no-op store", () => {
+ const store = createLocalStore<string>("noop", { storage: undefined });
+
+ // All operations are safe no-ops
+ expect(store.load()).toBeNull();
+ expect(() => store.save("hello")).not.toThrow();
+ expect(() => store.clear()).not.toThrow();
+ });
+
+ it("round-trips arrays", () => {
+ const storage = createMemoryStorage();
+ const store = createLocalStore<number[]>("arr", { storage });
+
+ store.save([1, 2, 3]);
+ expect(store.load()).toEqual([1, 2, 3]);
+ });
+
+ it("round-trips nested objects", () => {
+ const storage = createMemoryStorage();
+ const store = createLocalStore<{ a: { b: string[] } }>("nested", { storage });
+
+ store.save({ a: { b: ["x", "y"] } });
+ expect(store.load()).toEqual({ a: { b: ["x", "y"] } });
+ });
+
+ it("overwrites previous value on repeated save", () => {
+ const storage = createMemoryStorage();
+ const store = createLocalStore<string>("key", { storage });
+
+ store.save("first");
+ store.save("second");
+ expect(store.load()).toBe("second");
+ });
+});
diff --git a/src/adapters/local-storage/index.ts b/src/adapters/local-storage/index.ts
new file mode 100644
index 0000000..72135ce
--- /dev/null
+++ b/src/adapters/local-storage/index.ts
@@ -0,0 +1,58 @@
+export interface LocalStore<T> {
+ load(): T | null;
+ save(value: T): void;
+ clear(): void;
+}
+
+export interface CreateLocalStoreOptions {
+ storage?: Storage | undefined;
+}
+
+function createNoopStore<T>(): LocalStore<T> {
+ return {
+ load() {
+ return null;
+ },
+ save() {},
+ clear() {},
+ };
+}
+
+export function createLocalStore<T>(key: string, opts?: CreateLocalStoreOptions): LocalStore<T> {
+ let storage: Storage | undefined;
+ if (opts !== undefined && "storage" in opts) {
+ storage = opts.storage;
+ } else {
+ storage = globalThis.localStorage;
+ }
+
+ if (storage === undefined || storage === null) {
+ return createNoopStore<T>();
+ }
+
+ return {
+ load(): T | null {
+ try {
+ const raw = storage.getItem(key);
+ if (raw === null) {
+ return null;
+ }
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+ },
+
+ save(value: T): void {
+ try {
+ storage.setItem(key, JSON.stringify(value));
+ } catch {
+ // Swallow quota / write errors — persistence is best-effort.
+ }
+ },
+
+ clear(): void {
+ storage.removeItem(key);
+ },
+ };
+}
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>
diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts
index c68ed0d..89e81b8 100644
--- a/src/features/conversation-cache/cache.test.ts
+++ b/src/features/conversation-cache/cache.test.ts
@@ -171,3 +171,27 @@ describe("cache.evictIfOverBudget", () => {
expect(evicted).toEqual([]);
});
});
+
+describe("cache.delete", () => {
+ it("removes the conversation from the store", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+
+ await store.append("conv-1", [chunk(1), chunk(2)]);
+ await cache.delete("conv-1");
+
+ const stored = await store.load("conv-1");
+ expect(stored).toEqual([]);
+ });
+
+ it("then load returns []", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+
+ await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]);
+ await cache.delete("conv-1");
+
+ const result = await cache.load("conv-1");
+ expect(result).toEqual([]);
+ });
+});
diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts
index 4aab487..3d5743a 100644
--- a/src/features/conversation-cache/cache.ts
+++ b/src/features/conversation-cache/cache.ts
@@ -20,6 +20,9 @@ export interface ConversationCache {
* Returns the evicted conversationIds.
*/
evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>;
+
+ /** Delete all cached data for a single conversation (local forget). */
+ delete(conversationId: string): Promise<void>;
}
export interface ConversationCacheOptions {
@@ -67,5 +70,9 @@ export function createConversationCache(
}
return toEvict;
},
+
+ async delete(conversationId) {
+ await store.delete(conversationId);
+ },
};
}
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
new file mode 100644
index 0000000..c01d4ac
--- /dev/null
+++ b/src/features/tabs/index.ts
@@ -0,0 +1,14 @@
+export type { Tab, TabsState } from "./tabs";
+export {
+ activeTab,
+ closeTab,
+ createTab,
+ deriveTitle,
+ initialState,
+ newDraft,
+ selectTab,
+ setModel,
+ setTitle,
+} from "./tabs";
+export type { TabsStorage, TabsStore } from "./tabs-store.svelte";
+export { createTabsStore } from "./tabs-store.svelte";
diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts
new file mode 100644
index 0000000..cba527e
--- /dev/null
+++ b/src/features/tabs/tabs-store.svelte.ts
@@ -0,0 +1,67 @@
+import type { Tab, TabsState } from "./tabs";
+import {
+ initialState,
+ closeTab as reduceCloseTab,
+ createTab as reduceCreateTab,
+ newDraft as reduceNewDraft,
+ selectTab as reduceSelectTab,
+ setModel as reduceSetModel,
+ setTitle as reduceSetTitle,
+ activeTab as selectActiveTab,
+} from "./tabs";
+
+export interface TabsStorage {
+ load(): TabsState | null;
+ save(state: TabsState): void;
+}
+
+export interface TabsStore {
+ readonly tabs: readonly Tab[];
+ readonly activeConversationId: string | null;
+ readonly activeTab: Tab | null;
+ newDraft(): void;
+ createTab(tab: Tab): void;
+ selectTab(conversationId: string): void;
+ closeTab(conversationId: string): void;
+ setModel(conversationId: string, model: string): void;
+ setTitle(conversationId: string, title: string): void;
+}
+
+export function createTabsStore(storage: TabsStorage): TabsStore {
+ let state = $state<TabsState>(storage.load() ?? initialState());
+
+ function apply(next: TabsState): void {
+ state = next;
+ storage.save(next);
+ }
+
+ return {
+ get tabs(): readonly Tab[] {
+ return state.tabs;
+ },
+ get activeConversationId(): string | null {
+ return state.activeConversationId;
+ },
+ get activeTab(): Tab | null {
+ return selectActiveTab(state);
+ },
+ newDraft(): void {
+ apply(reduceNewDraft(state));
+ },
+ createTab(tab: Tab): void {
+ apply(reduceCreateTab(state, tab));
+ },
+ selectTab(conversationId: string): void {
+ apply(reduceSelectTab(state, conversationId));
+ },
+ closeTab(conversationId: string): void {
+ apply(reduceCloseTab(state, conversationId));
+ },
+ setModel(conversationId: string, model: string): void {
+ apply(reduceSetModel(state, conversationId, model));
+ },
+ setTitle(conversationId: string, title: string): void {
+ apply(reduceSetTitle(state, conversationId, title));
+ },
+ };
+}
diff --git a/src/features/tabs/tabs-store.test.ts b/src/features/tabs/tabs-store.test.ts
new file mode 100644
index 0000000..81ec8ad
--- /dev/null
+++ b/src/features/tabs/tabs-store.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from "vitest";
+import type { TabsState } from "./tabs";
+import type { TabsStorage } from "./tabs-store.svelte";
+import { createTabsStore } from "./tabs-store.svelte";
+
+function createMemoryStorage(initial?: TabsState): TabsStorage & { data: TabsState | null } {
+ let data: TabsState | null = initial ?? null;
+ return {
+ get data() {
+ return data;
+ },
+ set data(v: TabsState | null) {
+ data = v;
+ },
+ load() {
+ return data;
+ },
+ save(state: TabsState) {
+ data = state;
+ },
+ };
+}
+
+describe("createTabsStore", () => {
+ it("loads persisted state on construct", () => {
+ const persisted: TabsState = {
+ tabs: [{ conversationId: "c1", model: "m1", title: "T1" }],
+ activeConversationId: "c1",
+ };
+ const storage = createMemoryStorage(persisted);
+ const store = createTabsStore(storage);
+
+ expect(store.tabs).toHaveLength(1);
+ expect(store.activeConversationId).toBe("c1");
+ expect(store.activeTab?.conversationId).toBe("c1");
+ });
+
+ it("starts with empty draft when no persisted state", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+ expect(store.activeTab).toBeNull();
+ });
+
+ it("saves after every mutation", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
+ expect(storage.data?.tabs).toHaveLength(1);
+ expect(storage.data?.activeConversationId).toBe("c1");
+
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+ expect(storage.data?.tabs).toHaveLength(2);
+
+ store.selectTab("c1");
+ expect(storage.data?.activeConversationId).toBe("c1");
+
+ store.closeTab("c1");
+ expect(storage.data?.tabs).toHaveLength(1);
+ expect(storage.data?.activeConversationId).toBe("c2");
+
+ store.setModel("c2", "new-model");
+ expect(storage.data?.tabs[0]?.model).toBe("new-model");
+
+ store.setTitle("c2", "New Title");
+ expect(storage.data?.tabs[0]?.title).toBe("New Title");
+
+ store.newDraft();
+ expect(storage.data?.activeConversationId).toBeNull();
+ });
+
+ it("createTab appends and activates", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
+ expect(store.tabs).toHaveLength(1);
+ expect(store.activeConversationId).toBe("c1");
+
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+ expect(store.tabs).toHaveLength(2);
+ expect(store.activeConversationId).toBe("c2");
+ });
+
+ it("selectTab changes active", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+
+ store.selectTab("c1");
+ expect(store.activeConversationId).toBe("c1");
+ });
+
+ it("closeTab removes and activates neighbour", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+ store.createTab({ conversationId: "c3", model: "m3", title: "T3" });
+
+ store.selectTab("c2");
+ store.closeTab("c2");
+ expect(store.tabs).toHaveLength(2);
+ expect(store.activeConversationId).toBe("c1");
+ });
+
+ it("closing the last tab returns to draft", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
+ store.closeTab("c1");
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+ });
+
+ it("setModel updates the right tab", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "old", title: "T1" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+
+ store.setModel("c1", "new-model");
+ expect(store.tabs[0]?.model).toBe("new-model");
+ expect(store.tabs[1]?.model).toBe("m2");
+ });
+
+ it("setTitle updates the right tab", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "Old" });
+
+ store.setTitle("c1", "New Title");
+ expect(store.tabs[0]?.title).toBe("New Title");
+ });
+
+ it("newDraft clears active but keeps tabs", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+
+ store.newDraft();
+ expect(store.tabs).toHaveLength(2);
+ expect(store.activeConversationId).toBeNull();
+ expect(store.activeTab).toBeNull();
+ });
+});
diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts
new file mode 100644
index 0000000..3034e76
--- /dev/null
+++ b/src/features/tabs/tabs.test.ts
@@ -0,0 +1,191 @@
+import { describe, expect, it } from "vitest";
+import type { Tab, TabsState } from "./tabs";
+import {
+ activeTab,
+ closeTab,
+ createTab,
+ deriveTitle,
+ initialState,
+ newDraft,
+ selectTab,
+ setModel,
+ setTitle,
+} from "./tabs";
+
+const tab = (conversationId: string, model = "default", title = "Chat"): Tab => ({
+ conversationId,
+ model,
+ title,
+});
+
+describe("initialState", () => {
+ it("returns empty draft state when no persisted state", () => {
+ const state = initialState();
+ expect(state.tabs).toEqual([]);
+ expect(state.activeConversationId).toBeNull();
+ });
+
+ it("returns persisted state when provided", () => {
+ const persisted: TabsState = {
+ tabs: [tab("c1")],
+ activeConversationId: "c1",
+ };
+ const state = initialState(persisted);
+ expect(state.tabs).toHaveLength(1);
+ expect(state.activeConversationId).toBe("c1");
+ });
+});
+
+describe("newDraft", () => {
+ it("sets activeConversationId to null", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = newDraft(state);
+ expect(next.activeConversationId).toBeNull();
+ });
+
+ it("keeps existing tabs", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = newDraft(state);
+ expect(next.tabs).toHaveLength(2);
+ });
+});
+
+describe("createTab", () => {
+ it("appends and activates", () => {
+ const state = initialState();
+ const next = createTab(state, tab("c1"));
+ expect(next.tabs).toHaveLength(1);
+ expect(next.tabs[0]?.conversationId).toBe("c1");
+ expect(next.activeConversationId).toBe("c1");
+ });
+
+ it("does not duplicate an existing conversationId", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = createTab(state, tab("c1"));
+ expect(next.tabs).toHaveLength(1);
+ });
+
+ it("activates an already-existing tab when createTab is called again", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
+ const next = createTab(state, tab("c1"));
+ expect(next.activeConversationId).toBe("c1");
+ });
+});
+
+describe("selectTab", () => {
+ it("changes active", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = selectTab(state, "c2");
+ expect(next.activeConversationId).toBe("c2");
+ });
+});
+
+describe("closeTab", () => {
+ it("removes the tab", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = closeTab(state, "c2");
+ expect(next.tabs).toHaveLength(1);
+ expect(next.tabs[0]?.conversationId).toBe("c1");
+ });
+
+ it("closing the active tab activates a neighbour (previous preferred)", () => {
+ const state: TabsState = {
+ tabs: [tab("c1"), tab("c2"), tab("c3")],
+ activeConversationId: "c2",
+ };
+ const next = closeTab(state, "c2");
+ expect(next.activeConversationId).toBe("c1");
+ });
+
+ it("closing the first active tab activates the next", () => {
+ const state: TabsState = {
+ tabs: [tab("c1"), tab("c2"), tab("c3")],
+ activeConversationId: "c1",
+ };
+ const next = closeTab(state, "c1");
+ expect(next.activeConversationId).toBe("c2");
+ });
+
+ it("closing the last tab returns to draft (null active)", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = closeTab(state, "c1");
+ expect(next.tabs).toHaveLength(0);
+ expect(next.activeConversationId).toBeNull();
+ });
+
+ it("closing a non-active tab does not change active", () => {
+ const state: TabsState = {
+ tabs: [tab("c1"), tab("c2"), tab("c3")],
+ activeConversationId: "c3",
+ };
+ const next = closeTab(state, "c1");
+ expect(next.activeConversationId).toBe("c3");
+ });
+
+ it("closing a non-existent tab is a no-op", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = closeTab(state, "missing");
+ expect(next).toEqual(state);
+ });
+});
+
+describe("setModel", () => {
+ it("updates the right tab", () => {
+ const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" };
+ const next = setModel(state, "c1", "new-model");
+ expect(next.tabs[0]?.model).toBe("new-model");
+ expect(next.tabs[1]?.model).toBe("default");
+ });
+});
+
+describe("setTitle", () => {
+ it("updates the right tab", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = setTitle(state, "c1", "Updated title");
+ expect(next.tabs[0]?.title).toBe("Updated title");
+ expect(next.tabs[1]?.title).toBe("Chat");
+ });
+});
+
+describe("activeTab", () => {
+ it("returns the active tab", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
+ expect(activeTab(state)?.conversationId).toBe("c2");
+ });
+
+ it("returns null when activeConversationId is null", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: null };
+ expect(activeTab(state)).toBeNull();
+ });
+
+ it("returns null when active tab is not found in tabs", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" };
+ expect(activeTab(state)).toBeNull();
+ });
+});
+
+describe("deriveTitle", () => {
+ it("truncates long messages with ellipsis", () => {
+ const msg = "This is a very long message that should be truncated at some point";
+ expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026");
+ });
+
+ it("returns full message when under max", () => {
+ expect(deriveTitle("Short", 40)).toBe("Short");
+ });
+
+ it("collapses whitespace", () => {
+ expect(deriveTitle(" hello world ")).toBe("hello world");
+ });
+
+ it("falls back to 'New chat' for empty input", () => {
+ expect(deriveTitle("")).toBe("New chat");
+ expect(deriveTitle(" ")).toBe("New chat");
+ });
+
+ it("uses default max of ~40 chars", () => {
+ const msg = "a".repeat(50);
+ const result = deriveTitle(msg);
+ expect(result).toBe(`${"a".repeat(40)}\u2026`);
+ });
+});
diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts
new file mode 100644
index 0000000..9af522f
--- /dev/null
+++ b/src/features/tabs/tabs.ts
@@ -0,0 +1,74 @@
+export interface Tab {
+ readonly conversationId: string;
+ readonly model: string;
+ readonly title: string;
+}
+
+export interface TabsState {
+ readonly tabs: readonly Tab[];
+ readonly activeConversationId: string | null;
+}
+
+const DEFAULT_TITLE = "New chat";
+const DEFAULT_MAX_TITLE_LENGTH = 40;
+
+export function initialState(persisted?: TabsState): TabsState {
+ if (persisted !== undefined) return persisted;
+ return { tabs: [], activeConversationId: null };
+}
+
+export function newDraft(state: TabsState): TabsState {
+ return { ...state, activeConversationId: null };
+}
+
+export function createTab(state: TabsState, tab: Tab): TabsState {
+ const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
+ const tabs = exists ? state.tabs : [...state.tabs, tab];
+ return { tabs, activeConversationId: tab.conversationId };
+}
+
+export function selectTab(state: TabsState, conversationId: string): TabsState {
+ return { ...state, activeConversationId: conversationId };
+}
+
+export function closeTab(state: TabsState, conversationId: string): TabsState {
+ const idx = state.tabs.findIndex((t) => t.conversationId === conversationId);
+ if (idx === -1) return state;
+
+ const tabs = state.tabs.filter((t) => t.conversationId !== conversationId);
+
+ if (state.activeConversationId !== conversationId) {
+ return { tabs, activeConversationId: state.activeConversationId };
+ }
+
+ if (tabs.length === 0) {
+ return { tabs, activeConversationId: null };
+ }
+
+ // prefer previous tab, else next
+ const neighborIdx = idx > 0 ? idx - 1 : 0;
+ const neighbor = tabs[neighborIdx];
+ return { tabs, activeConversationId: neighbor?.conversationId ?? null };
+}
+
+export function setModel(state: TabsState, conversationId: string, model: string): TabsState {
+ const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t));
+ return { tabs, activeConversationId: state.activeConversationId };
+}
+
+export function setTitle(state: TabsState, conversationId: string, title: string): TabsState {
+ const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t));
+ return { tabs, activeConversationId: state.activeConversationId };
+}
+
+export function activeTab(state: TabsState): Tab | null {
+ if (state.activeConversationId === null) return null;
+ return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null;
+}
+
+export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LENGTH): string {
+ const trimmed = message.trim().replace(/\s+/g, " ");
+ if (trimmed.length === 0) return DEFAULT_TITLE;
+ if (trimmed.length <= max) return trimmed;
+ return `${trimmed.slice(0, max)}\u2026`;
+}