import { create } from "zustand"; import { persist } from "zustand/middleware"; import { nanoid } from "nanoid"; import type { Conversation, Message, AppSettings, ConversationTag } from "./types"; import { DEFAULT_MODEL } from "./constants"; const DEFAULT_SETTINGS: AppSettings = { // General theme: "dark", fontSize: { chat: 14, code: 13 }, sendOnEnter: true, showTimestamps: false, compactMode: false, // Model model: DEFAULT_MODEL, maxTokens: 8096, temperature: 1.0, systemPrompt: "", // API apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001", apiKey: "", streamingEnabled: true, // Permissions permissions: { autoApprove: { file_read: false, file_write: false, bash: false, web_search: false, }, restrictedDirs: [], }, // MCP mcpServers: [], // Keybindings keybindings: { "new-conversation": "Ctrl+Shift+N", "send-message": "Enter", "focus-input": "Ctrl+L", "toggle-sidebar": "Ctrl+B", "open-settings": "Ctrl+,", "command-palette": "Ctrl+K", }, // Privacy telemetryEnabled: false, }; interface ChatState { conversations: Conversation[]; activeConversationId: string | null; settings: AppSettings; settingsOpen: boolean; // Sidebar state sidebarOpen: boolean; sidebarWidth: number; sidebarTab: "chats" | "history" | "files" | "settings"; pinnedIds: string[]; searchQuery: string; // Search & selection state (not persisted) isSearchOpen: boolean; selectedConversationIds: string[]; // Persisted search/tag state recentSearches: string[]; tags: ConversationTag[]; // Conversation actions createConversation: () => string; setActiveConversation: (id: string) => void; deleteConversation: (id: string) => void; renameConversation: (id: string, title: string) => void; updateConversation: (id: string, updates: Partial>) => void; addMessage: (conversationId: string, message: Omit) => string; updateMessage: (conversationId: string, messageId: string, updates: Partial) => void; /** Keep only the first `keepCount` messages in the conversation. */ truncateMessages: (conversationId: string, keepCount: number) => void; getActiveConversation: () => Conversation | null; // Bulk actions toggleSelectConversation: (id: string) => void; clearSelection: () => void; bulkDeleteConversations: (ids: string[]) => void; // Tag actions createTag: (label: string, color: string) => string; deleteTag: (id: string) => void; updateTag: (id: string, updates: Partial>) => void; tagConversation: (conversationId: string, tagId: string) => void; untagConversation: (conversationId: string, tagId: string) => void; // Search actions openSearch: () => void; closeSearch: () => void; addRecentSearch: (query: string) => void; clearRecentSearches: () => void; // Settings actions updateSettings: (settings: Partial) => void; resetSettings: (section?: string) => void; openSettings: () => void; closeSettings: () => void; // Sidebar actions toggleSidebar: () => void; setSidebarWidth: (w: number) => void; setSidebarTab: (tab: "chats" | "history" | "files" | "settings") => void; pinConversation: (id: string) => void; setSearchQuery: (q: string) => void; } export const useChatStore = create()( persist( (set, get) => ({ conversations: [], activeConversationId: null, settings: DEFAULT_SETTINGS, settingsOpen: false, sidebarOpen: true, sidebarWidth: 280, sidebarTab: "chats", pinnedIds: [], searchQuery: "", isSearchOpen: false, selectedConversationIds: [], recentSearches: [], tags: [], createConversation: () => { const id = nanoid(); const now = Date.now(); const conversation: Conversation = { id, title: "New conversation", messages: [], createdAt: now, updatedAt: now, model: get().settings.model, tags: [], }; set((state) => ({ conversations: [conversation, ...state.conversations], activeConversationId: id, sidebarTab: "chats", })); return id; }, setActiveConversation: (id) => { set({ activeConversationId: id, sidebarTab: "chats" }); }, deleteConversation: (id) => { set((state) => { const remaining = state.conversations.filter((c) => c.id !== id); const nextActive = state.activeConversationId === id ? (remaining[0]?.id ?? null) : state.activeConversationId; return { conversations: remaining, activeConversationId: nextActive, pinnedIds: state.pinnedIds.filter((pid) => pid !== id), }; }); }, renameConversation: (id, title) => { set((state) => ({ conversations: state.conversations.map((c) => c.id === id ? { ...c, title, updatedAt: Date.now() } : c ), })); }, updateConversation: (id, updates) => { set((state) => ({ conversations: state.conversations.map((c) => c.id === id ? { ...c, ...updates, updatedAt: Date.now() } : c ), })); }, addMessage: (conversationId, message) => { const id = nanoid(); const now = Date.now(); set((state) => ({ conversations: state.conversations.map((c) => c.id === conversationId ? { ...c, messages: [...c.messages, { ...message, id, createdAt: now }], updatedAt: now, } : c ), })); return id; }, updateMessage: (conversationId, messageId, updates) => { set((state) => ({ conversations: state.conversations.map((c) => c.id === conversationId ? { ...c, messages: c.messages.map((m) => m.id === messageId ? { ...m, ...updates } : m ), updatedAt: Date.now(), } : c ), })); }, toggleSelectConversation: (id) => { set((state) => ({ selectedConversationIds: state.selectedConversationIds.includes(id) ? state.selectedConversationIds.filter((sid) => sid !== id) : [...state.selectedConversationIds, id], })); }, clearSelection: () => set({ selectedConversationIds: [] }), bulkDeleteConversations: (ids) => { set((state) => { const idSet = new Set(ids); const remaining = state.conversations.filter((c) => !idSet.has(c.id)); const nextActive = state.activeConversationId && idSet.has(state.activeConversationId) ? (remaining[0]?.id ?? null) : state.activeConversationId; return { conversations: remaining, activeConversationId: nextActive, pinnedIds: state.pinnedIds.filter((pid) => !idSet.has(pid)), selectedConversationIds: [], }; }); }, createTag: (label, color) => { const id = nanoid(); set((state) => ({ tags: [...state.tags, { id, label, color }] })); return id; }, deleteTag: (id) => { set((state) => ({ tags: state.tags.filter((t) => t.id !== id), conversations: state.conversations.map((c) => ({ ...c, tags: c.tags?.filter((tid) => tid !== id), })), })); }, updateTag: (id, updates) => { set((state) => ({ tags: state.tags.map((t) => (t.id === id ? { ...t, ...updates } : t)), })); }, tagConversation: (conversationId, tagId) => { set((state) => ({ conversations: state.conversations.map((c) => c.id === conversationId ? { ...c, tags: [...new Set([...(c.tags ?? []), tagId])] } : c ), })); }, untagConversation: (conversationId, tagId) => { set((state) => ({ conversations: state.conversations.map((c) => c.id === conversationId ? { ...c, tags: c.tags?.filter((tid) => tid !== tagId) } : c ), })); }, openSearch: () => set({ isSearchOpen: true }), closeSearch: () => set({ isSearchOpen: false }), addRecentSearch: (query) => { if (!query.trim()) return; set((state) => ({ recentSearches: [ query, ...state.recentSearches.filter((s) => s !== query), ].slice(0, 10), })); }, clearRecentSearches: () => set({ recentSearches: [] }), updateSettings: (settings) => { set((state) => ({ settings: { ...state.settings, ...settings }, })); }, resetSettings: (section) => { if (!section) { set({ settings: DEFAULT_SETTINGS }); return; } const sectionDefaults: Record> = { general: { theme: DEFAULT_SETTINGS.theme, fontSize: DEFAULT_SETTINGS.fontSize, sendOnEnter: DEFAULT_SETTINGS.sendOnEnter, showTimestamps: DEFAULT_SETTINGS.showTimestamps, compactMode: DEFAULT_SETTINGS.compactMode, }, model: { model: DEFAULT_SETTINGS.model, maxTokens: DEFAULT_SETTINGS.maxTokens, temperature: DEFAULT_SETTINGS.temperature, systemPrompt: DEFAULT_SETTINGS.systemPrompt, }, api: { apiUrl: DEFAULT_SETTINGS.apiUrl, streamingEnabled: DEFAULT_SETTINGS.streamingEnabled, }, permissions: { permissions: DEFAULT_SETTINGS.permissions }, keybindings: { keybindings: DEFAULT_SETTINGS.keybindings }, data: { telemetryEnabled: DEFAULT_SETTINGS.telemetryEnabled }, }; const defaults = sectionDefaults[section]; if (defaults) { set((state) => ({ settings: { ...state.settings, ...defaults } })); } }, openSettings: () => set({ settingsOpen: true }), closeSettings: () => set({ settingsOpen: false }), toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), setSidebarWidth: (w) => set({ sidebarWidth: w }), setSidebarTab: (tab) => set({ sidebarTab: tab }), pinConversation: (id) => set((state) => ({ pinnedIds: state.pinnedIds.includes(id) ? state.pinnedIds.filter((pid) => pid !== id) : [id, ...state.pinnedIds], })), setSearchQuery: (q) => set({ searchQuery: q }), getActiveConversation: () => { const state = get(); return ( state.conversations.find((c) => c.id === state.activeConversationId) ?? null ); }, }), { name: "claude-code-chat", partialize: (state) => ({ conversations: state.conversations, activeConversationId: state.activeConversationId, settings: state.settings, pinnedIds: state.pinnedIds, recentSearches: state.recentSearches, tags: state.tags, }), merge: (persisted, current) => ({ ...current, ...(persisted as object), settings: { ...DEFAULT_SETTINGS, ...((persisted as { settings?: Partial }).settings ?? {}), }, // Never persist UI state settingsOpen: false, isSearchOpen: false, sidebarTab: "chats", selectedConversationIds: [], }), } ) );