diff options
Diffstat (limited to 'src/features/tabs')
| -rw-r--r-- | src/features/tabs/index.ts | 14 | ||||
| -rw-r--r-- | src/features/tabs/tabs-store.svelte.ts | 67 | ||||
| -rw-r--r-- | src/features/tabs/tabs-store.test.ts | 157 | ||||
| -rw-r--r-- | src/features/tabs/tabs.test.ts | 191 | ||||
| -rw-r--r-- | src/features/tabs/tabs.ts | 74 |
5 files changed, 503 insertions, 0 deletions
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`; +} |
