import { writable, derived, get } from "svelte/store"; import type { TerminalLine, ConnectionStatus, PermissionRequest, UserQuestionEvent, Attachment, } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; import { cleanupConversationTracking } from "$lib/tauri"; import { characterState } from "$lib/stores/character"; import { sessionsStore } from "$lib/stores/sessions"; import { agentStore } from "$lib/stores/agents"; export interface ConversationSummary { generatedAt: Date; content: string; messageCount: number; tokenEstimate: number; } export interface Conversation { id: string; name: string; terminalLines: TerminalLine[]; sessionId: string | null; connectionStatus: ConnectionStatus; workingDirectory: string; characterState: CharacterState; isProcessing: boolean; grantedTools: Set; pendingPermissions: PermissionRequest[]; pendingQuestion: UserQuestionEvent | null; scrollPosition: number; createdAt: Date; lastActivityAt: Date; attachments: Attachment[]; summary: ConversationSummary | null; startedAt: Date; taskStartTime: number | null; successSoundFired: boolean; taskStartSoundFired: boolean; draftText: string; } const TAB_NAMES = [ // Cosmic & celestial "Starfall", "Moonbeam", "Nebula", "Aurora", "Stardust", "Solstice", "Comet", "Eclipse", "Zenith", "Celestia", "Nova", "Quasar", "Lyra", "Andromeda", "Twilight", // Magical & fantastical "Camelot", "Reverie", "Arcane", "Spellbound", "Mirage", "Oracle", "Seraphim", "Ethereal", "Labyrinth", "Enchantment", // Nature & cosy "Sakura", "Ember", "Cascade", "Zephyr", "Serendipity", "Solace", "Blossom", "Whisper", "Dewdrop", "Sunbeam", "Willow", "Clover", "Honeybee", "Buttercup", "Dandelion", // Japanese/anime-inspired "Tsukimi", "Hanami", "Yozora", "Hoshi", "Koharu", "Akari", "Midori", // Adventure & epic "Odyssey", "Wanderer", "Horizon", "Voyage", "Pathfinder", "Frontier", // Whimsical & sweet "Bubblegum", "Marshmallow", "Daydream", "Whimsy", "Jellybean", "Sprinkle", "Cupcake", // Dreamy & poetic "Revenant", "Elysium", "Halcyon", "Ephemera", "Serenade", "Lullaby", "Nocturne", "Rhapsody", ]; function pickRandomTabName(): string { return TAB_NAMES[Math.floor(Math.random() * TAB_NAMES.length)]; } function createConversationsStore() { const conversations = writable>(new Map()); const activeConversationId = writable(null); const pendingRetryMessage = writable(null); let conversationCounter = 0; let lineIdCounter = 0; function generateConversationId(): string { return `conv-${Date.now()}-${conversationCounter++}`; } function generateLineId(): string { return `line-${Date.now()}-${lineIdCounter++}`; } function createNewConversation(name?: string): Conversation { const id = generateConversationId(); return { id, name: name ?? pickRandomTabName(), terminalLines: [], sessionId: null, connectionStatus: "disconnected", workingDirectory: "", characterState: "idle", isProcessing: false, grantedTools: new Set(), pendingPermissions: [], pendingQuestion: null, scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll) createdAt: new Date(), lastActivityAt: new Date(), attachments: [], summary: null, startedAt: new Date(), taskStartTime: null, successSoundFired: false, taskStartSoundFired: false, draftText: "", }; } // Initialize with first conversation lazily let initialized = false; function ensureInitialized() { if (!initialized) { initialized = true; const initialConversation = createNewConversation(); conversations.update((convs) => { convs.set(initialConversation.id, initialConversation); return convs; }); activeConversationId.set(initialConversation.id); } } // Derived store for current conversation const activeConversation = derived( [conversations, activeConversationId], ([$conversations, $activeId]) => { if (!$activeId) return null; return $conversations.get($activeId) || null; } ); // Derived stores for compatibility with existing code const connectionStatus = derived( activeConversation, ($conv) => $conv?.connectionStatus || "disconnected" ); const terminalLines = derived(activeConversation, ($conv) => { return $conv?.terminalLines || []; }); const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null); const currentWorkingDirectory = derived( activeConversation, ($conv) => $conv?.workingDirectory || "" ); const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false); const grantedTools = derived( activeConversation, ($conv) => $conv?.grantedTools || new Set() ); const pendingPermission = derived( activeConversation, ($conv) => $conv?.pendingPermissions[0] || null ); const pendingPermissions = derived( activeConversation, ($conv) => $conv?.pendingPermissions || [] ); const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []); return { // Expose derived stores for compatibility connectionStatus: { subscribe: connectionStatus.subscribe }, sessionId: { subscribe: sessionId.subscribe }, currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe }, terminalLines: { subscribe: terminalLines.subscribe }, pendingPermission: { subscribe: pendingPermission.subscribe }, pendingPermissions: { subscribe: pendingPermissions.subscribe }, pendingQuestion: { subscribe: pendingQuestion.subscribe }, isProcessing: { subscribe: isProcessing.subscribe }, grantedTools: { subscribe: grantedTools.subscribe }, pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, scrollPosition: { subscribe: scrollPosition.subscribe }, attachments: { subscribe: attachments.subscribe }, // New conversation-specific stores conversations: { subscribe: conversations.subscribe }, activeConversationId: { subscribe: activeConversationId.subscribe }, activeConversation: { subscribe: activeConversation.subscribe }, // Connection management setConnectionStatus: (status: ConnectionStatus) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.connectionStatus = status; conv.lastActivityAt = new Date(); } return convs; }); }, setConnectionStatusForConversation: (conversationId: string, status: ConnectionStatus) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.connectionStatus = status; conv.lastActivityAt = new Date(); } return convs; }); }, setCharacterStateForConversation: (conversationId: string, state: CharacterState) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.characterState = state; // If this is the active conversation, update the global character state if (conversationId === get(activeConversationId)) { characterState.setState(state); } } return convs; }); }, requestPermission: (request: PermissionRequest) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.pendingPermissions = [...conv.pendingPermissions, request]; conv.lastActivityAt = new Date(); } return convs; }); }, clearPermission: () => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.pendingPermissions = []; conv.lastActivityAt = new Date(); } return convs; }); }, requestPermissionForConversation: (conversationId: string, request: PermissionRequest) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.pendingPermissions = [...conv.pendingPermissions, request]; conv.lastActivityAt = new Date(); } return convs; }); }, clearPermissionForConversation: (conversationId: string) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.pendingPermissions = []; conv.lastActivityAt = new Date(); } return convs; }); }, removePermission: (id: string) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id); conv.lastActivityAt = new Date(); } return convs; }); }, removePermissionForConversation: (conversationId: string, id: string) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id); conv.lastActivityAt = new Date(); } return convs; }); }, requestQuestion: (question: UserQuestionEvent) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.pendingQuestion = question; conv.lastActivityAt = new Date(); } return convs; }); }, clearQuestion: () => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.pendingQuestion = null; conv.lastActivityAt = new Date(); } return convs; }); }, requestQuestionForConversation: (conversationId: string, question: UserQuestionEvent) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.pendingQuestion = question; conv.lastActivityAt = new Date(); } return convs; }); }, clearQuestionForConversation: (conversationId: string) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.pendingQuestion = null; conv.lastActivityAt = new Date(); } return convs; }); }, setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message), // Conversation management createConversation: (name?: string) => { ensureInitialized(); const newConv = createNewConversation(name); conversations.update((convs) => { convs.set(newConv.id, newConv); return convs; }); activeConversationId.set(newConv.id); return newConv.id; }, deleteConversation: async (id: string) => { ensureInitialized(); const convs = get(conversations); const activeId = get(activeConversationId); if (convs.size <= 1) { // Don't delete the last conversation return false; } // Cancel any pending auto-save for this conversation sessionsStore.cancelAutoSave(id); // Clean up tracking for this conversation (including temp files) await cleanupConversationTracking(id); // Clean up agent tracking for this conversation // This prevents the badge from persisting after tab close agentStore.clearConversation(id); conversations.update((c) => { c.delete(id); return c; }); // If we deleted the active conversation, switch to another if (activeId === id) { const remaining = Array.from(get(conversations).keys()); if (remaining.length > 0) { activeConversationId.set(remaining[0]); } } return true; }, switchConversation: async (id: string) => { const convs = get(conversations); if (!convs.has(id)) return; const currentId = get(activeConversationId); const targetConv = convs.get(id); // If switching to a different conversation if (currentId !== id) { activeConversationId.set(id); // Update the global character state to match the conversation's state. // Map success/error → idle since those are transient states that have // already been displayed — restoring them would re-trigger sound rules. if (targetConv) { const stateToRestore = targetConv.characterState === "success" || targetConv.characterState === "error" ? "idle" : targetConv.characterState; characterState.setState(stateToRestore); } } }, renameConversation: (id: string, newName: string) => { conversations.update((convs) => { const conv = convs.get(id); if (conv) { conv.name = newName; conv.lastActivityAt = new Date(); } return convs; }); }, saveScrollPosition: (id: string, position: number) => { conversations.update((convs) => { const conv = convs.get(id); if (conv) { conv.scrollPosition = position; } return convs; }); }, getScrollPosition: (id: string): number => { const convs = get(conversations); const conv = convs.get(id); return conv?.scrollPosition ?? -1; }, // Methods that operate on the active conversation setSessionId: (id: string | null) => { ensureInitialized(); const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.sessionId = id; conv.lastActivityAt = new Date(); } return convs; }); }, setSessionIdForConversation: (conversationId: string, id: string | null) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.sessionId = id; conv.lastActivityAt = new Date(); } return convs; }); }, setWorkingDirectory: (dir: string) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.workingDirectory = dir; conv.lastActivityAt = new Date(); } return convs; }); }, setWorkingDirectoryForConversation: (conversationId: string, dir: string) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.workingDirectory = dir; conv.lastActivityAt = new Date(); } return convs; }); }, setProcessing: (processing: boolean) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.isProcessing = processing; conv.lastActivityAt = new Date(); } return convs; }); }, addLine: ( type: TerminalLine["type"], content: string, toolName?: string, cost?: TerminalLine["cost"], parentToolUseId?: string ) => { ensureInitialized(); const activeId = get(activeConversationId); if (!activeId) return ""; const line: TerminalLine = { id: generateLineId(), type, content, timestamp: new Date(), toolName, cost, parentToolUseId, }; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.terminalLines.push(line); conv.lastActivityAt = new Date(); // Schedule auto-save for this conversation sessionsStore.scheduleAutoSave(conv); } return convs; }); return line.id; }, addLineToConversation: ( conversationId: string, type: TerminalLine["type"], content: string, toolName?: string, cost?: TerminalLine["cost"], parentToolUseId?: string ) => { ensureInitialized(); const line: TerminalLine = { id: generateLineId(), type, content, timestamp: new Date(), toolName, cost, parentToolUseId, }; conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.terminalLines.push(line); conv.lastActivityAt = new Date(); // Schedule auto-save for this conversation sessionsStore.scheduleAutoSave(conv); } return convs; }); return line.id; }, updateLine: (id: string, content: string) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { const line = conv.terminalLines.find((l) => l.id === id); if (line) { line.content = content; conv.lastActivityAt = new Date(); } } return convs; }); }, appendToLine: (id: string, additionalContent: string) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { const line = conv.terminalLines.find((l) => l.id === id); if (line) { line.content += additionalContent; conv.lastActivityAt = new Date(); } } return convs; }); }, clearTerminal: () => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.terminalLines = []; conv.lastActivityAt = new Date(); } return convs; }); }, getConversationHistory: (): string => { const activeId = get(activeConversationId); if (!activeId) return ""; const convs = get(conversations); const conv = convs.get(activeId); if (!conv) return ""; const relevantLines = conv.terminalLines.filter( (line) => line.type === "user" || line.type === "assistant" ); if (relevantLines.length === 0) return ""; return relevantLines .map((line) => { const role = line.type === "user" ? "User" : "Assistant"; return `${role}: ${line.content}`; }) .join("\n\n"); }, grantTool: (toolName: string) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.grantedTools.add(toolName); conv.lastActivityAt = new Date(); } return convs; }); }, revokeAllTools: () => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.grantedTools.clear(); conv.lastActivityAt = new Date(); } return convs; }); }, isToolGranted: (toolName: string): boolean => { const activeId = get(activeConversationId); if (!activeId) return false; const convs = get(conversations); const conv = convs.get(activeId); return conv?.grantedTools.has(toolName) || false; }, // Attachment management addAttachment: (attachment: Attachment) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.attachments.push(attachment); conv.lastActivityAt = new Date(); } return convs; }); }, removeAttachment: (id: string) => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.attachments = conv.attachments.filter((a) => a.id !== id); conv.lastActivityAt = new Date(); } return convs; }); }, clearAttachments: () => { const activeId = get(activeConversationId); if (!activeId) return; conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { conv.attachments = []; conv.lastActivityAt = new Date(); } return convs; }); }, getAttachments: (): Attachment[] => { const activeId = get(activeConversationId); if (!activeId) return []; const convs = get(conversations); const conv = convs.get(activeId); return conv?.attachments || []; }, // Summary/compaction functions setSummary: (conversationId: string, summary: ConversationSummary) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.summary = summary; conv.lastActivityAt = new Date(); } return convs; }); }, clearSummary: (conversationId: string) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.summary = null; conv.lastActivityAt = new Date(); } return convs; }); }, getSummary: (conversationId: string): ConversationSummary | null => { const convs = get(conversations); const conv = convs.get(conversationId); return conv?.summary || null; }, // Estimate token count for a conversation (rough approximation: ~4 chars per token) estimateTokenCount: (conversationId: string): number => { const convs = get(conversations); const conv = convs.get(conversationId); if (!conv) return 0; const relevantLines = conv.terminalLines.filter( (line) => line.type === "user" || line.type === "assistant" ); const totalChars = relevantLines.reduce((sum, line) => sum + line.content.length, 0); return Math.ceil(totalChars / 4); }, // Get conversation content suitable for summarisation getConversationForSummary: (conversationId: string): string => { const convs = get(conversations); const conv = convs.get(conversationId); if (!conv) return ""; const relevantLines = conv.terminalLines.filter( (line) => line.type === "user" || line.type === "assistant" ); return relevantLines .map((line) => { const role = line.type === "user" ? "User" : "Assistant"; return `${role}: ${line.content}`; }) .join("\n\n"); }, // Compact conversation by keeping only recent messages compactConversation: (conversationId: string, keepRecentCount: number = 10) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv && conv.terminalLines.length > keepRecentCount) { // Keep system messages and the most recent user/assistant messages const systemLines = conv.terminalLines.filter( (line) => line.type !== "user" && line.type !== "assistant" ); const chatLines = conv.terminalLines.filter( (line) => line.type === "user" || line.type === "assistant" ); // Keep only the most recent chat messages const recentChatLines = chatLines.slice(-keepRecentCount); // Combine: system lines at original positions + recent chat lines conv.terminalLines = [...systemLines.slice(-5), ...recentChatLines]; conv.lastActivityAt = new Date(); } return convs; }); }, // Compact conversation with a summary - clears old messages and injects summary context compactWithSummary: ( conversationId: string, summaryContent: string, messageCount: number, tokenEstimate: number ) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { // Store the summary conv.summary = { generatedAt: new Date(), content: summaryContent, messageCount, tokenEstimate, }; // Clear all messages and add a context injection message conv.terminalLines = [ { id: generateLineId(), type: "system", content: `[Conversation compacted] Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens). Context preserved below.`, timestamp: new Date(), }, { id: generateLineId(), type: "system", content: `Previous Session Context:\n${summaryContent}`, timestamp: new Date(), }, ]; conv.lastActivityAt = new Date(); } return convs; }); }, // Sound tracking methods resetSoundState: (conversationId: string) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.taskStartTime = null; conv.successSoundFired = false; conv.taskStartSoundFired = false; } return convs; }); }, setTaskStartTime: (conversationId: string, time: number | null) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.taskStartTime = time; } return convs; }); }, markSuccessSoundFired: (conversationId: string) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.successSoundFired = true; } return convs; }); }, markTaskStartSoundFired: (conversationId: string) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.taskStartSoundFired = true; } return convs; }); }, setDraftText: (conversationId: string, text: string) => { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { conv.draftText = text; } return convs; }); }, // Add initialization helper initialize: () => { ensureInitialized(); }, }; } export const conversationsStore = createConversationsStore(); // Initialize immediately conversationsStore.initialize();