diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 02:06:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 02:06:55 +0900 |
| commit | 529c6a2bb56447fe93796111df3d4cc5a05fdd93 (patch) | |
| tree | 8db14b4b072b8a73ac85963f625b5bb3f77883ac /src/features/tabs/tabs.ts | |
| parent | 90c438c4562793eb09358f9d1a050d2267f4fca5 (diff) | |
| download | dispatch-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.ts | 74 |
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`; +} |
