/** * conversationsStore - Conversation lifecycle, persistence and navigation * * Owns conversation CRUD, message tree navigation, import/export and title * management, persisted through DatabaseService. Per-chat options (MCP * overrides, reasoning effort, cwd) live in ConversationPreferences, * composed as {@link ConversationsStore.preferences}. */ import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { ROUTES } from '$lib/constants'; import { MessageRole } from '$lib/enums'; import { ConversationTransferService } from '$lib/services/conversation-transfer.service'; import { DatabaseService } from '$lib/services/database.service'; import { MigrationService } from '$lib/services/migration.service'; import { RouterService } from '$lib/services/router.service'; // direct imports between stores, not via the barrel, to avoid circular deps import { ConversationPreferences, type ConversationsPreferencesHost } from '$lib/stores/conversations/preferences.svelte'; import { settingsStore } from '$lib/stores/settings/index.svelte'; import { tabsStore } from '$lib/stores/tabs.svelte'; import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils'; import { SvelteSet } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; class ConversationsStore implements ConversationsPreferencesHost { /** Currently active conversation */ activeConversation = $state(null); /** Messages in the active conversation (filtered by currNode path) */ activeMessages = $state([]); /** List of all conversations */ conversations = $state([]); /** Whether the store has been initialized */ isInitialized = $state(false); /** Per-chat options (MCP overrides, reasoning effort, cwd), composed here. */ private _preferences = new ConversationPreferences(this); /** * Listeners notified with the ids of conversations that were deleted. * Lets dependent stores (e.g. agenticStore) drop per-conversation state * without introducing a circular import back into this store. */ private conversationDeletionListeners = new Set<(convIds: string[]) => void>(); /** In-flight init run; shared by concurrent callers, reset on failure to allow retry */ private initPromise: Promise | null = null; /** * Memo of the last findMessageIndex() lookup. Streaming calls it once per * chunk for the same message, so a validated cache hit keeps that O(1) * instead of a linear scan of activeMessages on every token. */ private lastMessageIndex: { id: string; index: number } | null = null; get preferences() { return this._preferences; } /** * Adds a message to the active messages array */ addMessageToActive(message: DatabaseMessage): void { this.activeMessages.push(message); } /** * Applies a field update to a conversation row, mirroring it into both the * conversations list and the active conversation when it is the target. * Shared by the rename/pin/preferences flows so no caller can forget to * mirror one side. */ applyConversationUpdate(id: string, updates: Partial): void { const convIndex = this.conversations.findIndex((c) => c.id === id); if (convIndex !== -1) { const target = this.conversations[convIndex] as unknown as Record; for (const [key, value] of Object.entries(updates)) { if (target[key] !== value) target[key] = value; } } if (this.activeConversation?.id === id) { this.activeConversation = { ...this.activeConversation, ...updates }; } } /** * Derives a conversation title from its first message content and applies * it, honoring the title-generation setting. Shared by every flow that * edits or creates the first user message. */ async applyTitleFromContent(convId: string, content: string): Promise { await this.updateConversationName( convId, generateConversationTitle(content, Boolean(settingsStore.config.titleGenerationUseFirstLine)) ); } /** * Deletes multiple conversations in sequence. * Mirrors deleteConversation() per-id; navigates to the new-chat screen only * if the currently-open chat was among the deleted ones. * @param convIds - Conversation IDs to delete */ async bulkDeleteConversations(convIds: string[]): Promise { if (convIds.length === 0) return; try { const idsToRemove = new SvelteSet(convIds); // Collect all descendants recursively so the local cache stays consistent // even when deleteWithForks is omitted. const queue = [...convIds]; while (queue.length > 0) { const parentId = queue.pop()!; for (const c of this.conversations) { if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) { idsToRemove.add(c.id); queue.push(c.id); } } } const activeWasDeleted = this.activeConversation !== null && idsToRemove.has(this.activeConversation.id); await DatabaseService.bulkDeleteConversations([...idsToRemove]); this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id)); this.notifyConversationsDeleted([...idsToRemove]); if (activeWasDeleted) { const activeId = this.activeConversation!.id; tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId)); this.clearActiveConversation(); await tabsStore.close(activeId, activeId); } else { tabsStore.removeTabs([...idsToRemove]); } toast.success( idsToRemove.size === 1 ? 'Conversation deleted' : `${idsToRemove.size} conversations deleted` ); } catch (error) { console.error('Failed to bulk delete conversations:', error); toast.error('Failed to delete conversations'); } } /** * Bundles the given conversations into a single zip archive and triggers a * browser download (one JSONL file per conversation). * @param convIds - Conversation IDs to export */ async bulkExportConversations(convIds: string[]): Promise { if (convIds.length === 0) return; try { const fetched = await DatabaseService.getConversationsWithMessages(convIds); const activeId = this.activeConversation?.id; const overridden = fetched.get(activeId ?? ''); if (overridden && activeId) { overridden.conv = { ...this.activeConversation! }; } const exported = [...fetched.values()]; if (exported.length === 0) { toast.error('No conversations to export'); return; } ConversationTransferService.downloadConversationsArchive(exported); toast.success( exported.length === 1 ? 'Conversation exported' : `${exported.length} conversations exported` ); } catch (error) { console.error('Failed to bulk export conversations:', error); toast.error('Failed to export conversations'); } } /** * Toggles the pinned state of each conversation individually. * Mixed-pin selections are intentionally not normalised here; the bulk * action UI surfaces them as a disabled mixed-state instead. * @param convIds - Conversation IDs to toggle */ async bulkToggleConversationPin(convIds: string[]): Promise { if (convIds.length === 0) return; try { const updates = await DatabaseService.bulkToggleConversationPins(convIds); const activeId = this.activeConversation?.id; if (activeId && updates.has(activeId)) { this.activeConversation = { ...this.activeConversation!, pinned: updates.get(activeId)! }; } for (let i = 0; i < this.conversations.length; i++) { const newPinned = updates.get(this.conversations[i].id); if (newPinned !== undefined) this.conversations[i].pinned = newPinned; } toast.success( convIds.length === 1 ? 'Conversation pin toggled' : `Updated pin state for ${convIds.length} conversations` ); } catch (error) { console.error('Failed to bulk toggle pin:', error); toast.error('Failed to update pin state'); } } /** * Clears the active conversation and messages. */ clearActiveConversation(): void { this.activeConversation = null; this.activeMessages = []; // reload defaults so new chats inherit persisted state this.preferences.resetPending(); } /** * Creates a new conversation and navigates to it * @param name - Optional name for the conversation * @returns The ID of the created conversation */ async createConversation(name?: string): Promise { const conversationName = name || `Chat ${new Date().toLocaleString()}`; // The tool policy is seeded from the current defaults: edits made inside // the conversation afterwards live on its row and do not flow back into // the defaults. Working directory picked on the new-chat screen gets // threaded in here too, then cleared so it doesn't bleed onto subsequent // new chats. const conversation = await DatabaseService.createConversation(conversationName, { cwd: this.preferences.pendingCwd ?? undefined, reasoningEffort: this.preferences.pendingReasoningEffort, ...this.preferences.getToolPolicySnapshot() }); this.preferences.pendingCwd = null; this.conversations = [conversation, ...this.conversations]; this.activeConversation = conversation; this.activeMessages = []; await goto(RouterService.chat(conversation.id)); return conversation.id; } /** * Deletes all conversations and their messages */ async deleteAll(): Promise { try { const allConversations = await DatabaseService.getAllConversations(); const allIds = allConversations.map((c) => c.id); await DatabaseService.bulkDeleteConversations(allIds); this.clearActiveConversation(); this.conversations = []; tabsStore.clear(); this.notifyConversationsDeleted(allIds); toast.success('All conversations deleted'); await goto(ROUTES.START); } catch (error) { console.error('Failed to delete all conversations:', error); toast.error('Failed to delete conversations'); } } /** * Deletes a conversation and all its messages * @param convId - The conversation ID to delete */ async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise { try { await DatabaseService.deleteConversation(convId, options); if (options?.deleteWithForks) { // Collect all descendants recursively const idsToRemove = new SvelteSet([convId]); const queue = [convId]; while (queue.length > 0) { const parentId = queue.pop()!; for (const c of this.conversations) { if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) { idsToRemove.add(c.id); queue.push(c.id); } } } this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id)); if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) { const activeId = this.activeConversation.id; tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId)); this.clearActiveConversation(); await tabsStore.close(activeId, activeId); } else { tabsStore.removeTabs([...idsToRemove]); } this.notifyConversationsDeleted([...idsToRemove]); } else { // Reparent direct children to deleted conv's parent (or promote to top-level) const deletedConv = this.conversations.find((c) => c.id === convId); const newParent = deletedConv?.forkedFromConversationId; this.conversations = this.conversations .filter((c) => c.id !== convId) .map((c) => c.forkedFromConversationId === convId ? { ...c, forkedFromConversationId: newParent } : c ); if (this.activeConversation?.id === convId) { this.clearActiveConversation(); await tabsStore.close(convId, convId); } else { tabsStore.removeTabs([convId]); } this.notifyConversationsDeleted([convId]); } } catch (error) { console.error('Failed to delete conversation:', error); } } /** * Downloads a single conversation as a JSONL file, serializing the full message tree. * @param convId - The conversation ID to download */ async downloadConversation(convId: string): Promise { const conversation = this.activeConversation?.id === convId ? this.activeConversation : await DatabaseService.getConversation(convId); if (!conversation) return; const messages = await DatabaseService.getConversationMessages(convId); ConversationTransferService.downloadConversationFile({ conv: conversation, messages }); } /** * Finds the index of a message in active messages. * * The last lookup is memoized and reused when it still validates against * the current array (same id at the same position), which covers the * streaming hot path where the same message is looked up on every chunk * while the array itself only mutates by field. Any structural change * (splice, reassignment, reordering) fails validation and falls back to a * full scan. */ findMessageIndex(messageId: string): number { const last = this.lastMessageIndex; const messages = this.activeMessages; if ( last && last.id === messageId && last.index >= 0 && last.index < messages.length && messages[last.index]?.id === messageId ) { return last.index; } const index = messages.findIndex((m) => m.id === messageId); this.lastMessageIndex = { id: messageId, index }; return index; } /** * Forks a conversation at a specific message, creating a new conversation * containing messages from root up to the target message, then navigates to it. * * @param messageId - The message ID to fork at * @param options - Fork options (name and whether to include attachments) * @returns The new conversation ID, or null if fork failed */ async forkConversation( messageId: string, options: { name: string; includeAttachments: boolean } ): Promise { if (!this.activeConversation) return null; try { const newConv = await DatabaseService.forkConversation( this.activeConversation.id, messageId, options ); this.conversations = [newConv, ...this.conversations]; await goto(RouterService.chat(newConv.id)); toast.success('Conversation forked'); return newConv.id; } catch (error) { console.error('Failed to fork conversation:', error); toast.error('Failed to fork conversation'); return null; } } /** * Gets all messages for a specific conversation * @param convId - The conversation ID * @returns Array of messages */ async getConversationMessages(convId: string): Promise { return await DatabaseService.getConversationMessages(convId); } /** * Imports conversations from provided data (without file picker) * @param data - Array of conversation data with messages * @returns The conversations written to the database and the ones skipped */ async importConversationsData( data: ExportedConversations ): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> { const result = await DatabaseService.importConversations(data); await this.loadConversations(); return result; } /** * Initialize the store by loading conversations from database. * Safe to call multiple times: concurrent callers share a single run, * and a failed run can be retried by calling again. */ initialize(): Promise { if (!browser) return Promise.resolve(); if (this.initPromise) return this.initPromise; this.initPromise = (async () => { try { await MigrationService.runAllMigrations(); await this.loadConversations(); this.isInitialized = true; } catch (error) { console.error('Failed to initialize conversations:', error); this.initPromise = null; } })(); return this.initPromise; } /** * Loads a specific conversation and its messages * @param convId - The conversation ID to load * @returns True if conversation was loaded successfully */ async loadConversation(convId: string): Promise { try { const conversation = await DatabaseService.getConversation(convId); if (!conversation) { return false; } // Drop any cwd the user drafted on the empty new-chat screen - // it doesn't belong to this conversation. this.preferences.pendingCwd = null; this.activeConversation = conversation; if (conversation.currNode) { const allMessages = await DatabaseService.getConversationMessages(convId); const filteredMessages = filterByLeafNodeId( allMessages, conversation.currNode, false ) as DatabaseMessage[]; this.activeMessages = filteredMessages; } else { const messages = await DatabaseService.getConversationMessages(convId); this.activeMessages = messages; } return true; } catch (error) { console.error('Failed to load conversation:', error); return false; } } /** * Loads all conversations from the database */ async loadConversations(): Promise { const conversations = await DatabaseService.getAllConversations(); this.conversations = conversations; } /** * Navigates to a specific sibling branch by updating currNode and refreshing messages. * @param siblingId - The sibling message ID to navigate to */ async navigateToSibling(siblingId: string): Promise { if (!this.activeConversation) return; const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); const currentFirstUserMessage = this.activeMessages.find( (m) => m.role === MessageRole.USER && m.parent === rootMessage?.id ); const currentLeafNodeId = findLeafNode(allMessages, siblingId); await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId); this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId }; await this.refreshActiveMessages(); if (rootMessage && this.activeMessages.length > 0) { const newFirstUserMessage = this.activeMessages.find( (m) => m.role === MessageRole.USER && m.parent === rootMessage.id ); if ( newFirstUserMessage && newFirstUserMessage.content.trim() && (!currentFirstUserMessage || newFirstUserMessage.id !== currentFirstUserMessage.id || newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim()) ) { await this.applyTitleFromContent(this.activeConversation.id, newFirstUserMessage.content); } } } /** * Registers a listener invoked with the ids of deleted conversations. * Returns an unsubscribe function. */ onConversationsDeleted(listener: (convIds: string[]) => void): () => void { this.conversationDeletionListeners.add(listener); return () => this.conversationDeletionListeners.delete(listener); } /** * Start a fresh chat by navigating to the bare `#/` new-chat screen. The * chat layout opens a new-chat tab for it when Conversation tabs are on. */ async openNewChat(): Promise { this.clearActiveConversation(); await goto(ROUTES.START); } /** * Refreshes active messages based on currNode after branch navigation. */ async refreshActiveMessages(): Promise { if (!this.activeConversation) return; const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); if (allMessages.length === 0) { this.activeMessages = []; return; } const leafNodeId = this.activeConversation.currNode || allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id; const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[]; this.activeMessages = currentPath; } /** * Removes a message from active messages by index */ removeMessageAtIndex(index: number): DatabaseMessage | undefined { if (index !== -1) { return this.activeMessages.splice(index, 1)[0]; } return undefined; } /** * Removes messages from active messages starting at an index */ sliceActiveMessages(startIndex: number): void { this.activeMessages = this.activeMessages.slice(0, startIndex); } /** * Toggles the pinned status of a conversation. * @param convId - The conversation ID to toggle * @returns The new pinned status */ async toggleConversationPin(convId: string): Promise { try { const newPinnedState = await DatabaseService.toggleConversationPin(convId); this.applyConversationUpdate(convId, { pinned: newPinnedState }); return newPinnedState; } catch (error) { console.error('Failed to toggle conversation pin:', error); return false; } } /** * Updates the name of a conversation. * @param convId - The conversation ID to update * @param name - The new name for the conversation */ async updateConversationName(convId: string, name: string): Promise { try { await DatabaseService.updateConversation(convId, { name }); this.applyConversationUpdate(convId, { name }); } catch (error) { console.error('Failed to update conversation name:', error); } } /** * Marks a conversation as recently active: stamps lastModified (persisted) * and moves it to the top of the list. Only message-activity flows call * this; metadata updates (rename, pin, settings) do not. * * @param convId - Conversation that produced the activity, defaults to the active one */ updateConversationTimestamp(convId?: string): void { const targetId = convId ?? this.activeConversation?.id; if (!targetId) return; const now = Date.now(); const chatIndex = this.conversations.findIndex((c) => c.id === targetId); if (chatIndex !== -1) { this.conversations[chatIndex].lastModified = now; const updatedConv = this.conversations.splice(chatIndex, 1)[0]; this.conversations = [updatedConv, ...this.conversations]; } if (this.activeConversation?.id === targetId) { this.activeConversation = { ...this.activeConversation, lastModified: now }; } DatabaseService.updateConversation(targetId, { lastModified: now }).catch((error) => console.error('Failed to update conversation timestamp:', error) ); } /** * Updates the current node of the active conversation * @param nodeId - The new current node ID */ async updateCurrentNode(nodeId: string): Promise { if (!this.activeConversation) return; await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId); this.activeConversation = { ...this.activeConversation, currNode: nodeId }; } /** * Updates a message at a specific index in active messages */ updateMessageAtIndex(index: number, updates: Partial): void { const message = index === -1 ? undefined : this.activeMessages[index]; if (!message) return; // Assign field by field rather than replacing the object. Replacing it // changes the array slot, which invalidates every consumer that merely // walks the list - notably ChatMessages.displayMessages, which rebuilds // entries for every message in the conversation. Deep $state proxies make // per-field writes fine-grained, so only readers of the changed field wake. const target = message as unknown as Record; for (const [key, value] of Object.entries(updates)) { if (target[key] !== value) { target[key] = value; } } } /** * * * Import & Export * * */ private notifyConversationsDeleted(convIds: string[]): void { if (convIds.length === 0) return; for (const listener of this.conversationDeletionListeners) { listener(convIds); } } } export const conversationsStore = new ConversationsStore();