summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/tabs.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 02:06:55 +0900
committerAdam Malczewski <[email protected]>2026-06-07 02:06:55 +0900
commit529c6a2bb56447fe93796111df3d4cc5a05fdd93 (patch)
tree8db14b4b072b8a73ac85963f625b5bb3f77883ac /src/features/tabs/tabs.ts
parent90c438c4562793eb09358f9d1a050d2267f4fca5 (diff)
downloaddispatch-web-529c6a2bb56447fe93796111df3d4cc5a05fdd93.tar.gz
dispatch-web-529c6a2bb56447fe93796111df3d4cc5a05fdd93.zip
Slice 3 wave A: tabs model, model selector, cache delete, localStorage
- features/tabs: pure tab-workspace reducer (create/select/close/setModel/ setTitle/deriveTitle, draft=null active) + injected-persistence runes store - features/chat: mutable per-tab model (setModel) + delta routing guard (ignore foreign conversationId) + ModelSelector.svelte + DaisyUI chat bubbles / composer (keeps streaming <details> keying fix) - features/conversation-cache: surface delete(conversationId) on the wrapper for tab-close local-forget - adapters/local-storage: generic injected JSON localStore<T> (quota/corrupt-safe) Verified: svelte-check 0/0, vitest 273, biome clean, build ok.
Diffstat (limited to 'src/features/tabs/tabs.ts')
-rw-r--r--src/features/tabs/tabs.ts74
1 files changed, 74 insertions, 0 deletions
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`;
+}