From 2be20fab3c55f102c63196f97a5776f37c310321 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Sun, 11 Jan 2026 02:04:30 +0100 Subject: [PATCH 1/3] chore: init store --- package-lock.json | 32 +- package.json | 3 +- .../services/conversation.store.service.ts | 4 +- src/renderer/app/App.tsx | 20 +- src/renderer/stores/auth.store.ts | 166 +++++++ src/renderer/stores/chat.store.ts | 408 ++++++++++++++++++ src/renderer/stores/conversation.store.ts | 150 +++++++ src/renderer/stores/index.ts | 96 +++++ src/renderer/stores/models.store.ts | 218 ++++++++++ src/renderer/stores/tools.store.ts | 114 +++++ src/renderer/stores/types.ts | 40 ++ src/renderer/stores/workspace.store.ts | 160 +++++++ src/shared/types/conversation.types.ts | 12 +- src/shared/types/index.ts | 3 +- 14 files changed, 1413 insertions(+), 13 deletions(-) create mode 100644 src/renderer/stores/auth.store.ts create mode 100644 src/renderer/stores/chat.store.ts create mode 100644 src/renderer/stores/conversation.store.ts create mode 100644 src/renderer/stores/index.ts create mode 100644 src/renderer/stores/models.store.ts create mode 100644 src/renderer/stores/tools.store.ts create mode 100644 src/renderer/stores/types.ts create mode 100644 src/renderer/stores/workspace.store.ts diff --git a/package-lock.json b/package-lock.json index f8cce75..aa70931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "react-router-dom": "^7.11.0", "simple-git": "^3.30.0", "tailwind-merge": "^3.4.0", - "zod": "^4.3.5" + "zod": "^4.3.5", + "zustand": "^5.0.9" }, "devDependencies": { "@electron-toolkit/utils": "^4.0.0", @@ -13031,6 +13032,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 1629079..0053f3b 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "react-router-dom": "^7.11.0", "simple-git": "^3.30.0", "tailwind-merge": "^3.4.0", - "zod": "^4.3.5" + "zod": "^4.3.5", + "zustand": "^5.0.9" }, "devDependencies": { "@electron-toolkit/utils": "^4.0.0", diff --git a/src/main/services/conversation.store.service.ts b/src/main/services/conversation.store.service.ts index ef8d8e3..2cbce2a 100644 --- a/src/main/services/conversation.store.service.ts +++ b/src/main/services/conversation.store.service.ts @@ -14,10 +14,10 @@ import type { ConversationIndex, ConversationMessage, ConversationRef, + ConversationSessionConfig, ConversationSnapshot, CreateConversationOptions, ListConversationsOptions, - SessionConfig, ToolMessage, UserMessage, } from '../../shared/types/conversation.types.js'; @@ -508,7 +508,7 @@ export const restoreConversation = async ( ): Promise<{ id: string; messages: ChatMessage[]; - config: SessionConfig; + config: ConversationSessionConfig; createdAt: string; updatedAt: string; } | null> => { diff --git a/src/renderer/app/App.tsx b/src/renderer/app/App.tsx index 409c140..c05b129 100644 --- a/src/renderer/app/App.tsx +++ b/src/renderer/app/App.tsx @@ -1,8 +1,24 @@ +import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; +import { initStores, loadStores } from '../stores/index.js'; /** * Root application component - * Minimal - just renders the router outlet + * Initializes Zustand stores and renders the router outlet * All routing logic is handled by router.tsx */ -export const App = (): React.JSX.Element => ; +export const App = (): React.JSX.Element => { + useEffect(() => { + // Subscribe to IPC events + const cleanup = initStores(); + + // Load initial data + loadStores().catch((err: unknown) => { + console.error('Failed to load stores:', err); + }); + + return cleanup; + }, []); + + return ; +}; diff --git a/src/renderer/stores/auth.store.ts b/src/renderer/stores/auth.store.ts new file mode 100644 index 0000000..184dfed --- /dev/null +++ b/src/renderer/stores/auth.store.ts @@ -0,0 +1,166 @@ +/** + * Authentication store + * Manages user authentication state and GitHub connections + */ +import { create } from 'zustand'; +import type { LinkedProvider, OAuthProvider, User } from '../../shared/index.js'; +import type { BaseSlices, CleanupFn } from './types.js'; + +interface AuthState extends BaseSlices { + // Data slices + user: User | null; + provider: OAuthProvider | null; + linkedProviders: LinkedProvider[]; + + // Computed (implemented as getter methods) + isAuthenticated: () => boolean; + + // Actions + login: () => Promise; + logout: () => Promise; + checkAuth: () => Promise; + linkProvider: (provider: OAuthProvider) => Promise; + unlinkProvider: (provider: OAuthProvider) => Promise; + loadProviders: () => Promise; +} + +export const useAuthStore = create((set, get) => ({ + // Base slices + isLoading: true, // Start loading to check auth on init + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, + + // Data slices + user: null, + provider: null, + linkedProviders: [], + + // Computed + isAuthenticated: () => get().user !== null, + + // Actions + login: async () => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.auth.login(); + if (result.success && result.user) { + set({ + user: result.user, + isLoading: false, + }); + // Load linked providers after login + await get().loadProviders(); + return true; + } + set({ error: result.error ?? 'Login failed', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + logout: async () => { + set({ isLoading: true, error: null }); + try { + await window.agentage.auth.logout(); + set({ + user: null, + provider: null, + linkedProviders: [], + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, + + checkAuth: async () => { + set({ isLoading: true, error: null }); + try { + const user = await window.agentage.auth.getUser(); + if (user) { + set({ user, isLoading: false }); + // Load linked providers if authenticated + await get().loadProviders(); + } else { + set({ user: null, isLoading: false }); + } + } catch (err) { + set({ error: String(err), user: null, isLoading: false }); + } + }, + + linkProvider: async (provider: OAuthProvider) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.auth.linkProvider(provider); + if (result.success && result.provider) { + const linkedProvider = result.provider; + set((state) => ({ + linkedProviders: [...state.linkedProviders, linkedProvider], + isLoading: false, + })); + return true; + } + set({ error: result.error ?? 'Failed to link provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + unlinkProvider: async (provider: OAuthProvider) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.auth.unlinkProvider(provider); + if (result.success) { + set((state) => ({ + linkedProviders: state.linkedProviders.filter((p) => p.name !== provider), + isLoading: false, + })); + return true; + } + set({ error: result.error ?? 'Failed to unlink provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + loadProviders: async () => { + try { + const providers = await window.agentage.auth.getProviders(); + set({ linkedProviders: providers }); + } catch (err) { + console.error('Failed to load linked providers:', err); + } + }, +})); + +/** + * Initialize auth store event subscriptions + * Call once at app mount + */ +export const initAuthStore = + (): CleanupFn => + // No specific events for auth in current API + // Could add auth:changed event listener if backend supports it + () => { + // Cleanup function + }; + +/** + * Load auth state - check if user is authenticated + */ +export const loadAuth = (): Promise => useAuthStore.getState().checkAuth(); diff --git a/src/renderer/stores/chat.store.ts b/src/renderer/stores/chat.store.ts new file mode 100644 index 0000000..ce7663a --- /dev/null +++ b/src/renderer/stores/chat.store.ts @@ -0,0 +1,408 @@ +/** + * Chat store + * Manages active chat session state and streaming + */ +import { create } from 'zustand'; +import type { + ChatAgentInfo, + ChatEvent, + ChatMessage, + ChatModelInfo, + ChatReference, + ChatStopReason, + ChatToolInfo, + SessionConfig, + ToolCall, + ToolResult, +} from '../../shared/index.js'; +import type { BaseSlices, CleanupFn } from './types.js'; + +interface ChatState extends BaseSlices { + // Session + conversationId: string | null; + messages: ChatMessage[]; + + // Streaming state + isStreaming: boolean; + streamText: string; + streamThinking: string; + requestId: string | null; + + // Config + model: string | null; + agent: ChatAgentInfo | null; + systemPrompt: string; + enabledTools: string[]; + + // Available options (cached) + availableModels: ChatModelInfo[]; + availableTools: ChatToolInfo[]; + availableAgents: ChatAgentInfo[]; + + // Actions + send: (content: string, references?: ChatReference[]) => Promise; + cancel: () => void; + clear: () => void; + + // Session management + newSession: () => void; + loadSession: (id: string, messages: ChatMessage[]) => void; + + // Config setters + setModel: (id: string) => void; + setAgent: (agent: ChatAgentInfo | null) => void; + setSystemPrompt: (prompt: string) => void; + setEnabledTools: (ids: string[]) => void; + + // Internal state updates (called by event listener) + appendMessage: (msg: ChatMessage) => void; + updateStreamText: (text: string) => void; + updateStreamThinking: (text: string) => void; + handleToolCall: (toolCallId: string, name: string, input: unknown) => void; + handleToolResult: (toolCallId: string, name: string, result: unknown, isError?: boolean) => void; + handleDone: (stopReason: ChatStopReason) => void; + handleError: (code: string, message: string) => void; + + // Load available options + loadModels: () => Promise; + loadTools: () => Promise; + loadAgents: () => Promise; +} + +export const useChatStore = create((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, + + // Session + conversationId: null, + messages: [], + + // Streaming state + isStreaming: false, + streamText: '', + streamThinking: '', + requestId: null, + + // Config + model: null, + agent: null, + systemPrompt: '', + enabledTools: [], + + // Available options + availableModels: [], + availableTools: [], + availableAgents: [], + + // Actions + send: async (content: string, references?: ChatReference[]) => { + const { model, agent, systemPrompt, enabledTools, conversationId } = get(); + + if (!model) { + set({ error: 'No model selected' }); + return; + } + + // Add user message optimistically + const userMessage: ChatMessage = { + role: 'user', + content, + references, + timestamp: new Date().toISOString(), + }; + set((state) => ({ + messages: [...state.messages, userMessage], + isStreaming: true, + streamText: '', + streamThinking: '', + error: null, + })); + + try { + const config: SessionConfig = { + conversationId: conversationId ?? undefined, + model, + system: agent ? undefined : systemPrompt || undefined, + agent: agent?.id, + tools: enabledTools.length > 0 ? enabledTools : undefined, + }; + + const response = await window.agentage.chat.send({ + prompt: content, + references, + config, + }); + + set({ + requestId: response.requestId, + conversationId: response.conversationId, + }); + } catch (err) { + set({ + error: String(err), + isStreaming: false, + }); + } + }, + + cancel: () => { + const { requestId } = get(); + if (requestId) { + window.agentage.chat.cancel(requestId); + set({ + isStreaming: false, + requestId: null, + }); + } + }, + + clear: () => { + window.agentage.chat.clear(); + set({ + conversationId: null, + messages: [], + streamText: '', + streamThinking: '', + requestId: null, + isStreaming: false, + error: null, + }); + }, + + // Session management + newSession: () => { + set({ + conversationId: null, + messages: [], + streamText: '', + streamThinking: '', + requestId: null, + isStreaming: false, + error: null, + }); + }, + + loadSession: (id: string, messages: ChatMessage[]) => { + set({ + conversationId: id, + messages, + streamText: '', + streamThinking: '', + requestId: null, + isStreaming: false, + error: null, + }); + }, + + // Config setters + setModel: (id: string) => { + set({ model: id }); + }, + setAgent: (agent: ChatAgentInfo | null) => { + set({ agent }); + }, + setSystemPrompt: (prompt: string) => { + set({ systemPrompt: prompt }); + }, + setEnabledTools: (ids: string[]) => { + set({ enabledTools: ids }); + }, + + // Internal state updates + appendMessage: (msg: ChatMessage) => { + set((state) => ({ + messages: [...state.messages, msg], + })); + }, + + updateStreamText: (text: string) => { + set((state) => ({ + streamText: state.streamText + text, + })); + }, + + updateStreamThinking: (text: string) => { + set((state) => ({ + streamThinking: state.streamThinking + text, + })); + }, + + handleToolCall: (toolCallId: string, name: string, input: unknown) => { + // Add tool call to current streaming message + const toolCall: ToolCall = { id: toolCallId, name, input }; + set((state) => { + const messages = [...state.messages]; + const lastMsg = messages.at(-1); + + // If last message is assistant, add tool call to it + if (lastMsg?.role === 'assistant') { + messages[messages.length - 1] = { + ...lastMsg, + toolCalls: [...(lastMsg.toolCalls ?? []), toolCall], + }; + } + + return { messages }; + }); + }, + + handleToolResult: (toolCallId: string, name: string, result: unknown, isError?: boolean) => { + const toolResult: ToolResult = { + id: toolCallId, + name, + result: typeof result === 'string' ? result : JSON.stringify(result), + isError, + }; + + set((state) => { + const messages = [...state.messages]; + + // Find the message with this tool call and add the result + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === 'assistant' && msg.toolCalls?.some((tc) => tc.id === toolCallId)) { + messages[i] = { + ...msg, + toolResults: [...(msg.toolResults ?? []), toolResult], + }; + break; + } + } + + return { messages }; + }); + }, + + handleDone: (stopReason: ChatStopReason) => { + const { streamText, streamThinking } = get(); + + // Finalize the assistant message + if (streamText || streamThinking) { + const assistantMessage: ChatMessage = { + role: 'assistant', + content: streamText, + timestamp: new Date().toISOString(), + }; + set((state) => ({ + messages: [...state.messages, assistantMessage], + isStreaming: false, + streamText: '', + streamThinking: '', + requestId: null, + })); + } else { + set({ + isStreaming: false, + requestId: null, + }); + } + + // Log stop reason for debugging + if (stopReason !== 'end_turn') { + console.log('Chat stopped:', stopReason); + } + }, + + handleError: (code: string, message: string) => { + set({ + error: `${code}: ${message}`, + isStreaming: false, + requestId: null, + }); + }, + + // Load available options + loadModels: async () => { + try { + const models = await window.agentage.chat.getModels(); + set({ availableModels: models }); + + // Set default model if none selected + if (!get().model && models.length > 0) { + set({ model: models[0].id }); + } + } catch (err) { + console.error('Failed to load models:', err); + } + }, + + loadTools: async () => { + try { + const tools = await window.agentage.chat.getTools(); + set({ availableTools: tools }); + } catch (err) { + console.error('Failed to load tools:', err); + } + }, + + loadAgents: async () => { + try { + const agents = await window.agentage.chat.getAgents(); + set({ availableAgents: agents }); + } catch (err) { + console.error('Failed to load agents:', err); + } + }, +})); + +/** + * Handle incoming chat events + */ +const handleChatEvent = (event: ChatEvent): void => { + const store = useChatStore.getState(); + + // Ignore events for different requests + if (store.requestId && event.requestId !== store.requestId) { + return; + } + + switch (event.type) { + case 'text': + store.updateStreamText(event.text); + break; + case 'thinking': + store.updateStreamThinking(event.text); + break; + case 'tool_call': + store.handleToolCall(event.toolCallId, event.name, event.input); + break; + case 'tool_result': + store.handleToolResult(event.toolCallId, event.name, event.result, event.isError); + break; + case 'done': + store.handleDone(event.stopReason); + break; + case 'error': + store.handleError(event.code, event.message); + break; + case 'usage': + // Could track token usage if needed + break; + } +}; + +/** + * Initialize chat store event subscriptions + */ +export const initChatStore = (): CleanupFn => { + const unsubscribe = window.agentage.chat.onEvent(handleChatEvent); + return unsubscribe; +}; + +/** + * Load chat options on app init + */ +export const loadChat = async (): Promise => { + const store = useChatStore.getState(); + await Promise.all([store.loadModels(), store.loadTools(), store.loadAgents()]); +}; diff --git a/src/renderer/stores/conversation.store.ts b/src/renderer/stores/conversation.store.ts new file mode 100644 index 0000000..d49c1bb --- /dev/null +++ b/src/renderer/stores/conversation.store.ts @@ -0,0 +1,150 @@ +/** + * Conversation store + * Manages conversation history and persistence + */ +import { create } from 'zustand'; +import type { + ConversationRef, + ConversationSnapshot, + ListConversationsOptions, +} from '../../shared/index.js'; +import type { BaseSlices, CleanupFn } from './types.js'; + +// Define restored conversation type based on API +interface RestoredConversation { + id: string; + messages: { + role: 'user' | 'assistant'; + content: string; + timestamp: string; + toolCalls?: { id: string; name: string; input: unknown }[]; + toolResults?: { id: string; name: string; result: string; isError?: boolean }[]; + }[]; + config: { + model: string; + system?: string; + agentId?: string; + tools?: string[]; + }; + createdAt: string; + updatedAt: string; +} + +interface ConversationState extends BaseSlices { + // Data slices + conversations: ConversationRef[]; + totalCount: number; + currentSnapshot: ConversationSnapshot | null; + + // Actions + load: (options?: ListConversationsOptions) => Promise; + restore: (id: string) => Promise; + delete: (id: string) => Promise; + deleteMultiple: (ids: string[]) => Promise; + + // Helpers + getById: (id: string) => ConversationRef | undefined; + getPinned: () => ConversationRef[]; + getRecent: (limit?: number) => ConversationRef[]; +} + +export const useConversationStore = create((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, + + // Data slices + conversations: [], + totalCount: 0, + currentSnapshot: null, + + // Actions + load: async (options?: ListConversationsOptions) => { + set({ isLoading: true, error: null }); + try { + const conversations = await window.agentage.conversations.list(options); + set({ + conversations, + totalCount: conversations.length, + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, + + restore: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const snapshot = await window.agentage.conversations.restore(id); + set({ isLoading: false }); + return snapshot; + } catch (err) { + set({ error: String(err), isLoading: false }); + return null; + } + }, + + delete: (id: string) => { + set({ isLoading: true, error: null }); + try { + // Note: Delete API might need to be added to preload + // For now, remove from local state + set((state) => ({ + conversations: state.conversations.filter((c) => c.id !== id), + totalCount: state.totalCount - 1, + isLoading: false, + })); + return Promise.resolve(true); + } catch (err) { + set({ error: String(err), isLoading: false }); + return Promise.resolve(false); + } + }, + + deleteMultiple: (ids: string[]) => { + set({ isLoading: true, error: null }); + try { + // Remove from local state + set((state) => ({ + conversations: state.conversations.filter((c) => !ids.includes(c.id)), + totalCount: state.totalCount - ids.length, + isLoading: false, + })); + return Promise.resolve(true); + } catch (err) { + set({ error: String(err), isLoading: false }); + return Promise.resolve(false); + } + }, + + // Helpers + getById: (id: string) => get().conversations.find((c) => c.id === id), + getPinned: () => get().conversations.filter((c) => c.isPinned), + getRecent: (limit = 10) => get().conversations.slice(0, limit), +})); + +/** + * Initialize conversation store event subscriptions + */ +export const initConversationStore = (): CleanupFn => { + const unsubscribe = window.agentage.conversations.onChange(() => { + // Reload conversations when they change + void useConversationStore.getState().load(); + }); + return unsubscribe; +}; + +/** + * Load conversations on app init + */ +export const loadConversations = (): Promise => useConversationStore.getState().load(); diff --git a/src/renderer/stores/index.ts b/src/renderer/stores/index.ts new file mode 100644 index 0000000..9b7ade2 --- /dev/null +++ b/src/renderer/stores/index.ts @@ -0,0 +1,96 @@ +/** + * Zustand stores entry point + * + * Architecture: UI ↔ Store ↔ IPC (UI never calls IPC directly) + * + * Usage: + * 1. Call initStores() once at app mount to set up event listeners + * 2. Call loadStores() after init to load initial data + * 3. Use store hooks in components: useAuthStore(), useChatStore(), etc. + */ + +// Auth store +import { initAuthStore, loadAuth, useAuthStore } from './auth.store.js'; + +// Models store +import { initModelsStore, loadProviders, useModelsStore } from './models.store.js'; + +// Tools store +import { initToolsStore, loadTools, useToolsStore } from './tools.store.js'; + +// Workspace store +import { initWorkspaceStore, loadWorkspaces, useWorkspaceStore } from './workspace.store.js'; + +// Conversation store +import { + initConversationStore, + loadConversations, + useConversationStore, +} from './conversation.store.js'; + +// Chat store +import { initChatStore, loadChat, useChatStore } from './chat.store.js'; + +// Re-export store hooks +export { + useAuthStore, + useChatStore, + useConversationStore, + useModelsStore, + useToolsStore, + useWorkspaceStore, +}; + +// Re-export init functions +export { + initAuthStore, + initChatStore, + initConversationStore, + initModelsStore, + initToolsStore, + initWorkspaceStore, +}; + +// Re-export load functions +export { loadAuth, loadChat, loadConversations, loadProviders, loadTools, loadWorkspaces }; + +// Type exports +export type * from './types.js'; + +/** + * Initialize all stores - subscribe to IPC events + * Call once at app mount + * @returns Cleanup function to unsubscribe from all events + */ +export const initStores = (): (() => void) => { + const cleanups = [ + initAuthStore(), + initModelsStore(), + initToolsStore(), + initWorkspaceStore(), + initConversationStore(), + initChatStore(), + ]; + + // Return cleanup function + return () => { + cleanups.forEach((fn) => { + fn(); + }); + }; +}; + +/** + * Load initial data for all stores + * Call after initStores() + */ +export const loadStores = async (): Promise => { + // Load auth first (determines what else to load) + await loadAuth(); + + // Load these in parallel (independent) + await Promise.all([loadProviders(), loadTools()]); + + // Load these after (may depend on above) + await Promise.all([loadWorkspaces(), loadConversations(), loadChat()]); +}; diff --git a/src/renderer/stores/models.store.ts b/src/renderer/stores/models.store.ts new file mode 100644 index 0000000..eef2ef9 --- /dev/null +++ b/src/renderer/stores/models.store.ts @@ -0,0 +1,218 @@ +/** + * Model Providers store + * Manages LLM providers and their models + */ +import { create } from 'zustand'; +import type { + ChatModelInfo, + ModelInfo, + ModelProviderConfig, + ModelProviderType, + TokenSource, +} from '../../shared/index.js'; +import type { BaseSlices, CleanupFn } from './types.js'; + +interface ModelsState extends BaseSlices { + // Data slices + providers: ModelProviderConfig[]; + chatModels: ChatModelInfo[]; + defaultModelId: string | null; + + // Discovery state + isValidating: boolean; + + // Computed getters + enabledProviders: () => ModelProviderConfig[]; + availableModels: () => ModelInfo[]; + getProviderByType: (type: ModelProviderType) => ModelProviderConfig | undefined; + + // Provider actions + loadProviders: (autoRefresh?: boolean) => Promise; + saveProvider: (config: { + provider: ModelProviderType; + source: TokenSource; + token?: string; + enabled: boolean; + models: ModelInfo[]; + }) => Promise; + deleteProvider: (provider: ModelProviderType) => Promise; + + // Model actions + validateToken: ( + provider: ModelProviderType, + token: string + ) => Promise<{ valid: boolean; models?: ModelInfo[]; error?: string }>; + loadChatModels: () => Promise; + setDefaultModel: (id: string) => void; + updateModelEnabled: ( + provider: ModelProviderType, + modelId: string, + enabled: boolean + ) => Promise; + setModelAsDefault: (provider: ModelProviderType, modelId: string) => Promise; +} + +export const useModelsStore = create((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, + + // Data slices + providers: [], + chatModels: [], + defaultModelId: null, + isValidating: false, + + // Computed getters + enabledProviders: () => get().providers.filter((p) => p.enabled), + availableModels: () => { + const enabled = get().enabledProviders(); + return enabled.flatMap((p) => p.models.filter((m) => m.enabled)); + }, + getProviderByType: (type: ModelProviderType) => get().providers.find((p) => p.provider === type), + + // Provider actions + loadProviders: async (autoRefresh = false) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.models.providers.load(autoRefresh); + set({ providers: result.providers, isLoading: false }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, + + saveProvider: async (config) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.models.providers.save({ + provider: config.provider, + source: config.source, + token: config.token, + enabled: config.enabled, + models: config.models, + lastFetchedAt: new Date().toISOString(), + }); + + if (result.success) { + // Reload providers to get updated state + await get().loadProviders(); + return true; + } + set({ error: result.error ?? 'Failed to save provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + deleteProvider: async (provider: ModelProviderType) => { + set({ isLoading: true, error: null }); + try { + // Save provider with empty models and disabled state + const result = await window.agentage.models.providers.save({ + provider, + source: 'manual', + enabled: false, + models: [], + }); + + if (result.success) { + set((state) => ({ + providers: state.providers.filter((p) => p.provider !== provider), + isLoading: false, + })); + return true; + } + set({ error: result.error ?? 'Failed to delete provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + // Model actions + validateToken: async (provider: ModelProviderType, token: string) => { + set({ isValidating: true, error: null }); + try { + const result = await window.agentage.models.validate({ provider, token }); + set({ isValidating: false }); + return { + valid: result.valid, + models: result.models, + error: result.error, + }; + } catch (err) { + set({ isValidating: false }); + return { valid: false, error: String(err) }; + } + }, + + loadChatModels: async () => { + try { + const models = await window.agentage.chat.getModels(); + set({ chatModels: models }); + } catch (err) { + console.error('Failed to load chat models:', err); + } + }, + + setDefaultModel: (id: string) => { + set({ defaultModelId: id }); + }, + + updateModelEnabled: async (provider: ModelProviderType, modelId: string, enabled: boolean) => { + const providerConfig = get().getProviderByType(provider); + if (!providerConfig) return false; + + const updatedModels = providerConfig.models.map((m) => + m.id === modelId ? { ...m, enabled } : m + ); + + return get().saveProvider({ + ...providerConfig, + models: updatedModels, + }); + }, + + setModelAsDefault: async (provider: ModelProviderType, modelId: string) => { + const providerConfig = get().getProviderByType(provider); + if (!providerConfig) return false; + + const updatedModels = providerConfig.models.map((m) => ({ + ...m, + isDefault: m.id === modelId, + })); + + return get().saveProvider({ + ...providerConfig, + models: updatedModels, + }); + }, +})); + +/** + * Initialize models store event subscriptions + */ +export const initModelsStore = (): CleanupFn => { + const unsubscribe = window.agentage.models.onChange((models) => { + useModelsStore.setState({ chatModels: models }); + }); + return unsubscribe; +}; + +/** + * Load providers on app init + */ +export const loadProviders = (): Promise => useModelsStore.getState().loadProviders(true); diff --git a/src/renderer/stores/tools.store.ts b/src/renderer/stores/tools.store.ts new file mode 100644 index 0000000..725106f --- /dev/null +++ b/src/renderer/stores/tools.store.ts @@ -0,0 +1,114 @@ +/** + * Tools store + * Manages tools and their enabled/disabled state + */ +import { create } from 'zustand'; +import type { ToolInfo } from '../../shared/index.js'; +import type { BaseSlices, CleanupFn } from './types.js'; + +interface ToolsState extends BaseSlices { + // Data slices + tools: ToolInfo[]; + enabledIds: string[]; + + // Computed getters + enabledTools: () => ToolInfo[]; + getToolByName: (name: string) => ToolInfo | undefined; + isToolEnabled: (name: string) => boolean; + + // Actions + load: () => Promise; + toggle: (name: string) => Promise; + setEnabled: (names: string[]) => Promise; + enableAll: () => Promise; + disableAll: () => Promise; +} + +export const useToolsStore = create((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, + + // Data slices + tools: [], + enabledIds: [], + + // Computed getters + enabledTools: () => { + const { tools, enabledIds } = get(); + return tools.filter((t) => enabledIds.includes(t.name)); + }, + getToolByName: (name: string) => get().tools.find((t) => t.name === name), + isToolEnabled: (name: string) => get().enabledIds.includes(name), + + // Actions + load: async () => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.tools.list(); + set({ + tools: result.tools, + enabledIds: result.settings.enabledTools, + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, + + toggle: async (name: string) => { + const { enabledIds } = get(); + const newEnabled = enabledIds.includes(name) + ? enabledIds.filter((id) => id !== name) + : [...enabledIds, name]; + + try { + await window.agentage.tools.updateSettings({ enabledTools: newEnabled }); + set({ enabledIds: newEnabled }); + } catch (err) { + set({ error: String(err) }); + } + }, + + setEnabled: async (names: string[]) => { + try { + await window.agentage.tools.updateSettings({ enabledTools: names }); + set({ enabledIds: names }); + } catch (err) { + set({ error: String(err) }); + } + }, + + enableAll: async () => { + const allNames = get().tools.map((t) => t.name); + await get().setEnabled(allNames); + }, + + disableAll: async () => { + await get().setEnabled([]); + }, +})); + +/** + * Initialize tools store event subscriptions + */ +export const initToolsStore = (): CleanupFn => { + const unsubscribe = window.agentage.tools.onChange((enabledTools) => { + useToolsStore.setState({ enabledIds: enabledTools }); + }); + return unsubscribe; +}; + +/** + * Load tools on app init + */ +export const loadTools = (): Promise => useToolsStore.getState().load(); diff --git a/src/renderer/stores/types.ts b/src/renderer/stores/types.ts new file mode 100644 index 0000000..1a1d92f --- /dev/null +++ b/src/renderer/stores/types.ts @@ -0,0 +1,40 @@ +/** + * Shared types for Zustand stores + */ + +/** + * Base slices included in every store + */ +export interface BaseSlices { + isLoading: boolean; + error: string | null; + + // Internal actions + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + clearError: () => void; +} + +/** + * Result wrapper for IPC operations + */ +export interface IpcResult { + success: boolean; + data?: T; + error?: string; +} + +/** + * Cleanup function returned by event subscriptions + */ +export type CleanupFn = () => void; + +/** + * Store initialization function signature + */ +export type InitStoreFn = () => CleanupFn; + +/** + * Store load function signature + */ +export type LoadStoreFn = () => Promise; diff --git a/src/renderer/stores/workspace.store.ts b/src/renderer/stores/workspace.store.ts new file mode 100644 index 0000000..e07b5b5 --- /dev/null +++ b/src/renderer/stores/workspace.store.ts @@ -0,0 +1,160 @@ +/** + * Workspace store + * Manages workspaces and active workspace selection + */ +import { create } from 'zustand'; +import type { Workspace, WorkspaceUpdate } from '../../shared/index.js'; +import type { BaseSlices, CleanupFn } from './types.js'; + +interface WorkspaceState extends BaseSlices { + // Data slices + workspaces: Workspace[]; + activeId: string | null; + + // Computed getters + active: () => Workspace | null; + getById: (id: string) => Workspace | undefined; + + // Actions + load: () => Promise; + select: (id: string) => Promise; + add: (path: string) => Promise; + remove: (id: string) => Promise; + update: (id: string, updates: WorkspaceUpdate) => Promise; + browse: () => Promise; + save: (id: string, message?: string) => Promise; +} + +export const useWorkspaceStore = create((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, + + // Data slices + workspaces: [], + activeId: null, + + // Computed getters + active: () => { + const { workspaces, activeId } = get(); + return workspaces.find((w) => w.id === activeId) ?? null; + }, + getById: (id: string) => get().workspaces.find((w) => w.id === id), + + // Actions + load: async () => { + set({ isLoading: true, error: null }); + try { + const [workspaces, active] = await Promise.all([ + window.agentage.workspace.list(), + window.agentage.workspace.getActive(), + ]); + set({ + workspaces, + activeId: active?.id ?? null, + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, + + select: async (id: string) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.switch(id); + set({ activeId: id, isLoading: false }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, + + add: async (path: string) => { + set({ isLoading: true, error: null }); + try { + const id = await window.agentage.workspace.add(path); + // Reload to get full workspace data with git status + await get().load(); + return id; + } catch (err) { + set({ error: String(err), isLoading: false }); + return null; + } + }, + + remove: async (id: string) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.remove(id); + set((state) => ({ + workspaces: state.workspaces.filter((w) => w.id !== id), + activeId: state.activeId === id ? null : state.activeId, + isLoading: false, + })); + return true; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + update: async (id: string, updates: WorkspaceUpdate) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.update(id, updates); + set((state) => ({ + workspaces: state.workspaces.map((w) => (w.id === id ? { ...w, ...updates } : w)), + isLoading: false, + })); + return true; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + browse: async () => { + try { + return await window.agentage.workspace.browse(); + } catch (err) { + set({ error: String(err) }); + return undefined; + } + }, + + save: async (id: string, message?: string) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.save(id, message); + // Reload to get updated git status + await get().load(); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, +})); + +/** + * Initialize workspace store event subscriptions + */ +export const initWorkspaceStore = (): CleanupFn => { + const unsubscribe = window.agentage.workspace.onListChanged(() => { + // Reload workspaces when list changes + void useWorkspaceStore.getState().load(); + }); + return unsubscribe; +}; + +/** + * Load workspaces on app init + */ +export const loadWorkspaces = (): Promise => useWorkspaceStore.getState().load(); diff --git a/src/shared/types/conversation.types.ts b/src/shared/types/conversation.types.ts index cfb349a..14c7761 100644 --- a/src/shared/types/conversation.types.ts +++ b/src/shared/types/conversation.types.ts @@ -85,9 +85,9 @@ export interface Reference { } /** - * Tool call in assistant message + * Tool call in assistant message (conversation-specific) */ -export interface ToolCall { +export interface ConversationToolCall { /** Tool call ID (generated by model or backend) */ id: string; @@ -157,7 +157,7 @@ export interface AssistantMessage { thinking?: string; /** Tool calls made by assistant (only present when finishReason is 'tool_use') */ - tool_calls?: ToolCall[]; + tool_calls?: ConversationToolCall[]; } /** @@ -195,9 +195,9 @@ export interface ToolMessage { export type ConversationMessage = UserMessage | AssistantMessage | ToolMessage; /** - * Session configuration for conversation + * Session configuration for conversation (conversation-specific) */ -export interface SessionConfig { +export interface ConversationSessionConfig { /** Model identifier (e.g., 'claude-sonnet-4-20250514', 'gpt-4o') */ model: string; @@ -287,7 +287,7 @@ export interface ConversationSnapshot { title: string; /** Session configuration */ - session: SessionConfig; + session: ConversationSessionConfig; /** Message history */ messages: ConversationMessage[]; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index adfb14a..746a5e7 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1,8 +1,9 @@ export * from './auth.types.js'; export * from './chat.types.js'; export * from './config.types.js'; -export * from './context.types.js'; export * from './context.data.types.js'; +export * from './context.types.js'; +export * from './conversation.types.js'; export * from './ipc.types.js'; export * from './model.providers.types.js'; export * from './navigation.types.js'; From 7e252c7a31bd99a42926d05989f1e6015b1c4237 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Sun, 11 Jan 2026 02:38:46 +0100 Subject: [PATCH 2/3] chore: add dev tools --- package-lock.json | 126 +++- package.json | 1 + src/main/index.ts | 16 +- .../layouts/components/WorkspaceSwitcher.tsx | 70 +- src/renderer/pages/tools/ToolsPage.tsx | 64 +- .../pages/tools/components/ToolCard.tsx | 6 +- src/renderer/pages/workspaces/page.tsx | 78 +-- src/renderer/stores/auth.store.ts | 236 +++---- src/renderer/stores/chat.store.ts | 639 +++++++++--------- src/renderer/stores/conversation.store.ts | 162 ++--- src/renderer/stores/models.store.ts | 305 +++++---- src/renderer/stores/tools.store.ts | 136 ++-- src/renderer/stores/workspace.store.ts | 222 +++--- 13 files changed, 1078 insertions(+), 983 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa70931..11f0441 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "cross-env": "^10.1.0", "electron": "^33.2.1", "electron-builder": "^25.1.8", + "electron-devtools-installer": "^4.0.0", "electron-vite": "^5.0.0", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", @@ -6789,6 +6790,16 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-devtools-installer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/electron-devtools-installer/-/electron-devtools-installer-4.0.0.tgz", + "integrity": "sha512-9Tntu/jtfSn0n6N/ZI6IdvRqXpDyLQiDuuIbsBI+dL+1Ef7C8J2JwByw58P3TJiNeuqyV3ZkphpNWuZK5iSY2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "unzip-crx-3": "^0.2.0" + } + }, "node_modules/electron-publish": { "version": "25.1.7", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", @@ -8300,6 +8311,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8514,8 +8532,7 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -9454,6 +9471,52 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9545,6 +9608,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -10755,6 +10828,13 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11082,8 +11162,7 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", @@ -11611,6 +11690,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12557,6 +12643,31 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unzip-crx-3": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/unzip-crx-3/-/unzip-crx-3-0.2.0.tgz", + "integrity": "sha512-0+JiUq/z7faJ6oifVB5nSwt589v1KCduqIJupNVDoWSXZtWDmjDGO3RAEOvwJ07w90aoXoP4enKsR7ecMrJtWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "^3.1.0", + "mkdirp": "^0.5.1", + "yaku": "^0.16.6" + } + }, + "node_modules/unzip-crx-3/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", @@ -12912,6 +13023,13 @@ "node": ">=10" } }, + "node_modules/yaku": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/yaku/-/yaku-0.16.7.tgz", + "integrity": "sha512-Syu3IB3rZvKvYk7yTiyl1bo/jiEFaaStrgv1V2TIJTqYPStSMQVO8EQjg/z+DRzLq/4LIIharNT3iH1hylEIRw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 0053f3b..32f1c84 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "cross-env": "^10.1.0", "electron": "^33.2.1", "electron-builder": "^25.1.8", + "electron-devtools-installer": "^4.0.0", "electron-vite": "^5.0.0", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/src/main/index.ts b/src/main/index.ts index 73479ad..8b02f2f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,5 @@ import { app, BrowserWindow, ipcMain, Menu, nativeImage } from 'electron'; +import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'; import { existsSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; @@ -137,7 +138,10 @@ const createWindow = (): BrowserWindow => { if (isDev && devServerUrl) { // In dev mode, wait for Vite dev server to be ready before loading // This prevents the common race condition where Electron starts before Vite - void waitForDevServer(devServerUrl, win); + void waitForDevServer(devServerUrl, win).then(() => { + // Open DevTools after page loads in dev mode + win.webContents.openDevTools({ mode: 'right' }); + }); } else { // In production, load the built HTML file void win.loadFile(join(__dirname, '../renderer/index.html')); @@ -161,6 +165,16 @@ const createWindow = (): BrowserWindow => { const initialize = async (): Promise => { await app.whenReady(); + // Install Redux DevTools in dev mode (for Zustand devtools) + if (isDev) { + try { + await installExtension(REDUX_DEVTOOLS); + console.log('✓ Redux DevTools installed'); + } catch (err) { + console.warn('Failed to install Redux DevTools:', err); + } + } + // Initialize logger and conversation store await initLogger(); await initConversationStore(); diff --git a/src/renderer/layouts/components/WorkspaceSwitcher.tsx b/src/renderer/layouts/components/WorkspaceSwitcher.tsx index 9e9a323..4568214 100644 --- a/src/renderer/layouts/components/WorkspaceSwitcher.tsx +++ b/src/renderer/layouts/components/WorkspaceSwitcher.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import type { Workspace } from '../../../shared/types/workspace.types.js'; import { cn } from '../../lib/utils.js'; import { WorkspaceIconDisplay } from '../../pages/workspaces/components/WorkspaceIconDisplay.js'; +import { useWorkspaceStore } from '../../stores/index.js'; // Chevron down icon (matching composer style) const ChevronDownIcon = (): React.JSX.Element => ( @@ -30,46 +30,29 @@ export const WorkspaceSwitcher = ({ isCollapsed = false, }: WorkspaceSwitcherProps): React.JSX.Element => { const navigate = useNavigate(); - const [workspaces, setWorkspaces] = useState([]); - const [activeWorkspace, setActiveWorkspace] = useState(null); + const { workspaces, activeId, select, active } = useWorkspaceStore(); const [isOpen, setIsOpen] = useState(false); - const loadWorkspaces = useCallback(async (): Promise => { - try { - const [list, active] = await Promise.all([ - window.agentage.workspace.list(), - window.agentage.workspace.getActive(), - ]); - setWorkspaces(list); - setActiveWorkspace(active); - } catch (error) { - console.error('Failed to load workspaces:', error); - } - }, []); - - useEffect(() => { - void loadWorkspaces(); + const activeWorkspace = active(); - // Subscribe to workspace list changes - const unsubscribe = window.agentage.workspace.onListChanged((): void => { - void loadWorkspaces(); - }); + const handleSwitchWorkspace = async (id: string): Promise => { + await select(id); + setIsOpen(false); + }; - return (): void => { - unsubscribe(); + // Close dropdown when pressing Escape + useEffect(() => { + const handleEscape = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setIsOpen(false); }; - }, [loadWorkspaces]); - - const handleSwitchWorkspace = async (id: string): Promise => { - try { - await window.agentage.workspace.switch(id); - const workspace = workspaces.find((w) => w.id === id); - if (workspace) setActiveWorkspace(workspace); - setIsOpen(false); - } catch (error) { - console.error('Failed to switch workspace:', error); + if (isOpen) { + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('keydown', handleEscape); + }; } - }; + return undefined; + }, [isOpen]); if (isCollapsed) { return ( @@ -109,7 +92,7 @@ export const WorkspaceSwitcher = ({ /> {/* Dropdown menu */} -
Default )} - {activeWorkspace?.id === workspace.id && ( + {activeId === workspace.id && ( @@ -192,7 +175,10 @@ export const WorkspaceSwitcher = ({ {activeWorkspace?.name ?? 'No workspace'} - + {activeWorkspace?.path ?? 'Select workspace'}
@@ -225,7 +211,7 @@ export const WorkspaceSwitcher = ({ className={cn( 'w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm', 'text-muted-foreground hover:text-foreground hover:bg-accent transition-colors text-left', - activeWorkspace?.id === workspace.id && 'bg-accent/50' + activeId === workspace.id && 'bg-accent/50' )} >
@@ -239,7 +225,7 @@ export const WorkspaceSwitcher = ({ {workspace.isDefault && ( Default )} - {activeWorkspace?.id === workspace.id && ( + {activeId === workspace.id && ( diff --git a/src/renderer/pages/tools/ToolsPage.tsx b/src/renderer/pages/tools/ToolsPage.tsx index 5b25dd4..071bbef 100644 --- a/src/renderer/pages/tools/ToolsPage.tsx +++ b/src/renderer/pages/tools/ToolsPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import type { TabFilter, ToolInfo, ToolSource } from '../../../shared/types/index.js'; +import type { TabFilter, ToolSource } from '../../../shared/types/index.js'; import { IconButton, RefreshIcon, @@ -8,6 +8,7 @@ import { type ToggleOption, } from '../../components/index.js'; import { cn } from '../../lib/utils.js'; +import { useToolsStore } from '../../stores/index.js'; import { ToolCard } from './components/ToolCard.js'; /** @@ -24,36 +25,19 @@ const TAB_OPTIONS: ToggleOption[] = [ * ToolsPage - Tool management interface * * Displays available tools with filtering by source. - * Phase 1: Builtin tools only, Global/Workspace show empty state. + * Uses useToolsStore for state management. */ export const ToolsPage = (): React.JSX.Element => { - const [tools, setTools] = useState([]); - const [enabledTools, setEnabledTools] = useState>(new Set()); + const { tools, enabledIds, isLoading, load, toggle, isToolEnabled } = useToolsStore(); const [activeTab, setActiveTab] = useState('all'); - const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - /** - * Load tools from main process - */ - const loadTools = useCallback(async (): Promise => { - try { - const result = await window.agentage.tools.list(); - setTools(result.tools); - setEnabledTools(new Set(result.settings.enabledTools)); - } catch (error) { - console.error('Failed to load tools:', error); - } finally { - setLoading(false); - } - }, []); - /** * Load tools on mount */ useEffect(() => { - void loadTools(); - }, [loadTools]); + void load(); + }, [load]); /** * Handle refresh button click @@ -61,38 +45,20 @@ export const ToolsPage = (): React.JSX.Element => { const handleRefresh = useCallback(async (): Promise => { setRefreshing(true); try { - await loadTools(); + await load(); } finally { setRefreshing(false); } - }, [loadTools]); + }, [load]); /** * Handle tool enable/disable toggle */ const handleToggle = useCallback( - async (toolName: string, enabled: boolean): Promise => { - // Optimistic update - const newEnabledTools = new Set(enabledTools); - if (enabled) { - newEnabledTools.add(toolName); - } else { - newEnabledTools.delete(toolName); - } - setEnabledTools(newEnabledTools); - - // Persist to backend - try { - await window.agentage.tools.updateSettings({ - enabledTools: Array.from(newEnabledTools), - }); - } catch (error) { - console.error('Failed to update tool settings:', error); - // Revert on error - setEnabledTools(enabledTools); - } + async (toolName: string): Promise => { + await toggle(toolName); }, - [enabledTools] + [toggle] ); /** @@ -107,7 +73,7 @@ export const ToolsPage = (): React.JSX.Element => { * Calculate statistics */ const totalCount = filteredTools.length; - const enabledCount = filteredTools.filter((t) => enabledTools.has(t.name)).length; + const enabledCount = filteredTools.filter((t) => enabledIds.includes(t.name)).length; /** * Check if tab has tools (for empty state) @@ -153,7 +119,7 @@ export const ToolsPage = (): React.JSX.Element => { ); }; - if (loading) { + if (isLoading) { return (
Loading...
@@ -195,8 +161,8 @@ export const ToolsPage = (): React.JSX.Element => { description={tool.description} source={tool.source} status={tool.status} - enabled={enabledTools.has(tool.name)} - onToggle={(enabled) => void handleToggle(tool.name, enabled)} + enabled={isToolEnabled(tool.name)} + onToggle={() => void handleToggle(tool.name)} /> )) : renderEmptyState(activeTab)} diff --git a/src/renderer/pages/tools/components/ToolCard.tsx b/src/renderer/pages/tools/components/ToolCard.tsx index ddb5c65..cd8a8a1 100644 --- a/src/renderer/pages/tools/components/ToolCard.tsx +++ b/src/renderer/pages/tools/components/ToolCard.tsx @@ -8,7 +8,7 @@ export interface ToolCardProps { source: ToolSource; status: ToolStatus; enabled: boolean; - onToggle: (enabled: boolean) => void; + onToggle: () => void; } /** @@ -57,7 +57,9 @@ export const ToolCard = ({ > { + onToggle(); + }} aria-label={`Enable ${name}`} className="shrink-0" /> diff --git a/src/renderer/pages/workspaces/page.tsx b/src/renderer/pages/workspaces/page.tsx index 762f20f..ca8b766 100644 --- a/src/renderer/pages/workspaces/page.tsx +++ b/src/renderer/pages/workspaces/page.tsx @@ -1,96 +1,48 @@ /** * WorkspacesPage - Workspace management + * Uses useWorkspaceStore for state management */ -import { useCallback, useEffect, useState } from 'react'; -import type { Workspace } from '../../../shared/types/workspace.types.js'; +import { useEffect } from 'react'; import { FolderIcon } from '../../components/index.js'; +import { useWorkspaceStore } from '../../stores/index.js'; import { DropZone } from './components/DropZone.js'; import { WorkspaceCard } from './components/WorkspaceCard.js'; export const WorkspacesPage = (): React.JSX.Element => { - const [workspaces, setWorkspaces] = useState([]); - const [activeId, setActiveId] = useState(null); - const [loading, setLoading] = useState(true); - - const loadWorkspaces = useCallback(async (): Promise => { - try { - const [list, active] = await Promise.all([ - window.agentage.workspace.list(), - window.agentage.workspace.getActive(), - ]); - setWorkspaces(list); - setActiveId(active?.id ?? null); - } catch (error) { - console.error('Failed to load workspaces:', error); - } finally { - setLoading(false); - } - }, []); + const { workspaces, activeId, isLoading, load, select, update, remove, add, browse } = + useWorkspaceStore(); useEffect(() => { - void loadWorkspaces(); - - // Subscribe to workspace list changes (e.g., from sidebar switcher) - const unsubscribe = window.agentage.workspace.onListChanged((): void => { - void loadWorkspaces(); - }); - - return (): void => { - unsubscribe(); - }; - }, [loadWorkspaces]); + void load(); + }, [load]); const handleSwitch = async (id: string): Promise => { - try { - await window.agentage.workspace.switch(id); - setActiveId(id); - } catch (error) { - console.error('Failed to switch workspace:', error); - } + await select(id); }; const handleUpdate = async ( id: string, updates: { name?: string; icon?: string; color?: string } ): Promise => { - try { - await window.agentage.workspace.update(id, updates); - setWorkspaces((prev) => prev.map((w) => (w.id === id ? { ...w, ...updates } : w))); - } catch (error) { - console.error('Failed to update workspace:', error); - } + await update(id, updates); }; const handleRemove = async (id: string): Promise => { - try { - await window.agentage.workspace.remove(id); - await loadWorkspaces(); - } catch (error) { - console.error('Failed to remove workspace:', error); - } + await remove(id); }; const handleAdd = async (path: string): Promise => { - try { - await window.agentage.workspace.add(path); - await loadWorkspaces(); - } catch (error) { - console.error('Failed to add workspace:', error); - } + await add(path); }; const handleBrowse = async (): Promise => { - try { - const path = await window.agentage.workspace.browse(); - if (path) { - await handleAdd(path); - } - } catch (error) { - console.error('Failed to browse folder:', error); + const path = await browse(); + if (path) { + await add(path); } }; - if (loading) { + if (isLoading) { return (
Loading workspaces...
diff --git a/src/renderer/stores/auth.store.ts b/src/renderer/stores/auth.store.ts index 184dfed..5b674ae 100644 --- a/src/renderer/stores/auth.store.ts +++ b/src/renderer/stores/auth.store.ts @@ -3,6 +3,7 @@ * Manages user authentication state and GitHub connections */ import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; import type { LinkedProvider, OAuthProvider, User } from '../../shared/index.js'; import type { BaseSlices, CleanupFn } from './types.js'; @@ -24,129 +25,134 @@ interface AuthState extends BaseSlices { loadProviders: () => Promise; } -export const useAuthStore = create((set, get) => ({ - // Base slices - isLoading: true, // Start loading to check auth on init - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, +export const useAuthStore = create()( + devtools( + (set, get) => ({ + // Base slices + isLoading: true, // Start loading to check auth on init + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, - // Data slices - user: null, - provider: null, - linkedProviders: [], + // Data slices + user: null, + provider: null, + linkedProviders: [], - // Computed - isAuthenticated: () => get().user !== null, + // Computed + isAuthenticated: () => get().user !== null, - // Actions - login: async () => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.auth.login(); - if (result.success && result.user) { - set({ - user: result.user, - isLoading: false, - }); - // Load linked providers after login - await get().loadProviders(); - return true; - } - set({ error: result.error ?? 'Login failed', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + // Actions + login: async () => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.auth.login(); + if (result.success && result.user) { + set({ + user: result.user, + isLoading: false, + }); + // Load linked providers after login + await get().loadProviders(); + return true; + } + set({ error: result.error ?? 'Login failed', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - logout: async () => { - set({ isLoading: true, error: null }); - try { - await window.agentage.auth.logout(); - set({ - user: null, - provider: null, - linkedProviders: [], - isLoading: false, - }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + logout: async () => { + set({ isLoading: true, error: null }); + try { + await window.agentage.auth.logout(); + set({ + user: null, + provider: null, + linkedProviders: [], + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - checkAuth: async () => { - set({ isLoading: true, error: null }); - try { - const user = await window.agentage.auth.getUser(); - if (user) { - set({ user, isLoading: false }); - // Load linked providers if authenticated - await get().loadProviders(); - } else { - set({ user: null, isLoading: false }); - } - } catch (err) { - set({ error: String(err), user: null, isLoading: false }); - } - }, + checkAuth: async () => { + set({ isLoading: true, error: null }); + try { + const user = await window.agentage.auth.getUser(); + if (user) { + set({ user, isLoading: false }); + // Load linked providers if authenticated + await get().loadProviders(); + } else { + set({ user: null, isLoading: false }); + } + } catch (err) { + set({ error: String(err), user: null, isLoading: false }); + } + }, - linkProvider: async (provider: OAuthProvider) => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.auth.linkProvider(provider); - if (result.success && result.provider) { - const linkedProvider = result.provider; - set((state) => ({ - linkedProviders: [...state.linkedProviders, linkedProvider], - isLoading: false, - })); - return true; - } - set({ error: result.error ?? 'Failed to link provider', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + linkProvider: async (provider: OAuthProvider) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.auth.linkProvider(provider); + if (result.success && result.provider) { + const linkedProvider = result.provider; + set((state) => ({ + linkedProviders: [...state.linkedProviders, linkedProvider], + isLoading: false, + })); + return true; + } + set({ error: result.error ?? 'Failed to link provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - unlinkProvider: async (provider: OAuthProvider) => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.auth.unlinkProvider(provider); - if (result.success) { - set((state) => ({ - linkedProviders: state.linkedProviders.filter((p) => p.name !== provider), - isLoading: false, - })); - return true; - } - set({ error: result.error ?? 'Failed to unlink provider', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + unlinkProvider: async (provider: OAuthProvider) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.auth.unlinkProvider(provider); + if (result.success) { + set((state) => ({ + linkedProviders: state.linkedProviders.filter((p) => p.name !== provider), + isLoading: false, + })); + return true; + } + set({ error: result.error ?? 'Failed to unlink provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - loadProviders: async () => { - try { - const providers = await window.agentage.auth.getProviders(); - set({ linkedProviders: providers }); - } catch (err) { - console.error('Failed to load linked providers:', err); - } - }, -})); + loadProviders: async () => { + try { + const providers = await window.agentage.auth.getProviders(); + set({ linkedProviders: providers }); + } catch (err) { + console.error('Failed to load linked providers:', err); + } + }, + }), + { name: 'auth' } + ) +); /** * Initialize auth store event subscriptions diff --git a/src/renderer/stores/chat.store.ts b/src/renderer/stores/chat.store.ts index ce7663a..8fc38f0 100644 --- a/src/renderer/stores/chat.store.ts +++ b/src/renderer/stores/chat.store.ts @@ -1,26 +1,34 @@ /** * Chat store * Manages active chat session state and streaming + * + * Messages array is the single source of truth - contains: + * - UserMessage (type: 'user') + * - AssistantMessage (type: 'assistant') - includes tool_calls + * - ToolMessage (type: 'tool') - tool execution results */ import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; import type { ChatAgentInfo, ChatEvent, - ChatMessage, ChatModelInfo, ChatReference, - ChatStopReason, ChatToolInfo, + ConversationMessage, SessionConfig, - ToolCall, - ToolResult, } from '../../shared/index.js'; import type { BaseSlices, CleanupFn } from './types.js'; +/** + * Generate unique message ID + */ +const generateId = (): string => crypto.randomUUID(); + interface ChatState extends BaseSlices { // Session conversationId: string | null; - messages: ChatMessage[]; + messages: ConversationMessage[]; // Streaming state isStreaming: boolean; @@ -28,13 +36,12 @@ interface ChatState extends BaseSlices { streamThinking: string; requestId: string | null; - // Config - model: string | null; - agent: ChatAgentInfo | null; - systemPrompt: string; - enabledTools: string[]; + // Selected config (user's current selection) + selectedModel: string | null; + selectedAgent: ChatAgentInfo | null; + selectedTools: string[]; - // Available options (cached) + // Available options (cached from backend) availableModels: ChatModelInfo[]; availableTools: ChatToolInfo[]; availableAgents: ChatAgentInfo[]; @@ -46,22 +53,12 @@ interface ChatState extends BaseSlices { // Session management newSession: () => void; - loadSession: (id: string, messages: ChatMessage[]) => void; + loadSession: (id: string, messages: ConversationMessage[]) => void; // Config setters - setModel: (id: string) => void; - setAgent: (agent: ChatAgentInfo | null) => void; - setSystemPrompt: (prompt: string) => void; - setEnabledTools: (ids: string[]) => void; - - // Internal state updates (called by event listener) - appendMessage: (msg: ChatMessage) => void; - updateStreamText: (text: string) => void; - updateStreamThinking: (text: string) => void; - handleToolCall: (toolCallId: string, name: string, input: unknown) => void; - handleToolResult: (toolCallId: string, name: string, result: unknown, isError?: boolean) => void; - handleDone: (stopReason: ChatStopReason) => void; - handleError: (code: string, message: string) => void; + setSelectedModel: (id: string) => void; + setSelectedAgent: (agent: ChatAgentInfo | null) => void; + setSelectedTools: (ids: string[]) => void; // Load available options loadModels: () => Promise; @@ -69,294 +66,198 @@ interface ChatState extends BaseSlices { loadAgents: () => Promise; } -export const useChatStore = create((set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, - - // Session - conversationId: null, - messages: [], - - // Streaming state - isStreaming: false, - streamText: '', - streamThinking: '', - requestId: null, - - // Config - model: null, - agent: null, - systemPrompt: '', - enabledTools: [], - - // Available options - availableModels: [], - availableTools: [], - availableAgents: [], - - // Actions - send: async (content: string, references?: ChatReference[]) => { - const { model, agent, systemPrompt, enabledTools, conversationId } = get(); - - if (!model) { - set({ error: 'No model selected' }); - return; - } - - // Add user message optimistically - const userMessage: ChatMessage = { - role: 'user', - content, - references, - timestamp: new Date().toISOString(), - }; - set((state) => ({ - messages: [...state.messages, userMessage], - isStreaming: true, - streamText: '', - streamThinking: '', +export const useChatStore = create()( + devtools( + (set, get) => ({ + // Base slices + isLoading: false, error: null, - })); - - try { - const config: SessionConfig = { - conversationId: conversationId ?? undefined, - model, - system: agent ? undefined : systemPrompt || undefined, - agent: agent?.id, - tools: enabledTools.length > 0 ? enabledTools : undefined, - }; - - const response = await window.agentage.chat.send({ - prompt: content, - references, - config, - }); - - set({ - requestId: response.requestId, - conversationId: response.conversationId, - }); - } catch (err) { - set({ - error: String(err), - isStreaming: false, - }); - } - }, - - cancel: () => { - const { requestId } = get(); - if (requestId) { - window.agentage.chat.cancel(requestId); - set({ - isStreaming: false, - requestId: null, - }); - } - }, - - clear: () => { - window.agentage.chat.clear(); - set({ + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, + + // Session conversationId: null, messages: [], - streamText: '', - streamThinking: '', - requestId: null, - isStreaming: false, - error: null, - }); - }, - // Session management - newSession: () => { - set({ - conversationId: null, - messages: [], - streamText: '', - streamThinking: '', - requestId: null, + // Streaming state isStreaming: false, - error: null, - }); - }, - - loadSession: (id: string, messages: ChatMessage[]) => { - set({ - conversationId: id, - messages, streamText: '', streamThinking: '', requestId: null, - isStreaming: false, - error: null, - }); - }, - // Config setters - setModel: (id: string) => { - set({ model: id }); - }, - setAgent: (agent: ChatAgentInfo | null) => { - set({ agent }); - }, - setSystemPrompt: (prompt: string) => { - set({ systemPrompt: prompt }); - }, - setEnabledTools: (ids: string[]) => { - set({ enabledTools: ids }); - }, - - // Internal state updates - appendMessage: (msg: ChatMessage) => { - set((state) => ({ - messages: [...state.messages, msg], - })); - }, - - updateStreamText: (text: string) => { - set((state) => ({ - streamText: state.streamText + text, - })); - }, - - updateStreamThinking: (text: string) => { - set((state) => ({ - streamThinking: state.streamThinking + text, - })); - }, - - handleToolCall: (toolCallId: string, name: string, input: unknown) => { - // Add tool call to current streaming message - const toolCall: ToolCall = { id: toolCallId, name, input }; - set((state) => { - const messages = [...state.messages]; - const lastMsg = messages.at(-1); - - // If last message is assistant, add tool call to it - if (lastMsg?.role === 'assistant') { - messages[messages.length - 1] = { - ...lastMsg, - toolCalls: [...(lastMsg.toolCalls ?? []), toolCall], - }; - } + // Selected config + selectedModel: null, + selectedAgent: null, + selectedTools: [], - return { messages }; - }); - }, - - handleToolResult: (toolCallId: string, name: string, result: unknown, isError?: boolean) => { - const toolResult: ToolResult = { - id: toolCallId, - name, - result: typeof result === 'string' ? result : JSON.stringify(result), - isError, - }; - - set((state) => { - const messages = [...state.messages]; - - // Find the message with this tool call and add the result - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.role === 'assistant' && msg.toolCalls?.some((tc) => tc.id === toolCallId)) { - messages[i] = { - ...msg, - toolResults: [...(msg.toolResults ?? []), toolResult], - }; - break; - } - } + // Available options + availableModels: [], + availableTools: [], + availableAgents: [], - return { messages }; - }); - }, + // Actions + send: async (content: string, references?: ChatReference[]) => { + const { selectedModel, selectedAgent, selectedTools, conversationId } = get(); - handleDone: (stopReason: ChatStopReason) => { - const { streamText, streamThinking } = get(); - - // Finalize the assistant message - if (streamText || streamThinking) { - const assistantMessage: ChatMessage = { - role: 'assistant', - content: streamText, - timestamp: new Date().toISOString(), - }; - set((state) => ({ - messages: [...state.messages, assistantMessage], - isStreaming: false, - streamText: '', - streamThinking: '', - requestId: null, - })); - } else { - set({ - isStreaming: false, - requestId: null, - }); - } + if (!selectedModel) { + set({ error: 'No model selected' }); + return; + } - // Log stop reason for debugging - if (stopReason !== 'end_turn') { - console.log('Chat stopped:', stopReason); - } - }, + // Create user message + const userMessage: ConversationMessage = { + type: 'user', + id: generateId(), + content, + timestamp: new Date().toISOString(), + references: references?.map((ref) => ({ + type: ref.type, + uri: ref.uri, + content: ref.content, + range: ref.range, + })), + }; - handleError: (code: string, message: string) => { - set({ - error: `${code}: ${message}`, - isStreaming: false, - requestId: null, - }); - }, + set((state) => ({ + messages: [...state.messages, userMessage], + isStreaming: true, + streamText: '', + streamThinking: '', + error: null, + })); + + try { + const config: SessionConfig = { + conversationId: conversationId ?? undefined, + model: selectedModel, + agent: selectedAgent?.id, + tools: selectedTools.length > 0 ? selectedTools : undefined, + }; - // Load available options - loadModels: async () => { - try { - const models = await window.agentage.chat.getModels(); - set({ availableModels: models }); - - // Set default model if none selected - if (!get().model && models.length > 0) { - set({ model: models[0].id }); - } - } catch (err) { - console.error('Failed to load models:', err); - } - }, - - loadTools: async () => { - try { - const tools = await window.agentage.chat.getTools(); - set({ availableTools: tools }); - } catch (err) { - console.error('Failed to load tools:', err); - } - }, - - loadAgents: async () => { - try { - const agents = await window.agentage.chat.getAgents(); - set({ availableAgents: agents }); - } catch (err) { - console.error('Failed to load agents:', err); - } - }, -})); + const response = await window.agentage.chat.send({ + prompt: content, + references, + config, + }); + + set({ + requestId: response.requestId, + conversationId: response.conversationId, + }); + } catch (err) { + set({ + error: String(err), + isStreaming: false, + }); + } + }, + + cancel: () => { + const { requestId } = get(); + if (requestId) { + window.agentage.chat.cancel(requestId); + set({ + isStreaming: false, + requestId: null, + }); + } + }, + + clear: () => { + window.agentage.chat.clear(); + set({ + conversationId: null, + messages: [], + streamText: '', + streamThinking: '', + requestId: null, + isStreaming: false, + error: null, + }); + }, + + // Session management + newSession: () => { + set({ + conversationId: null, + messages: [], + streamText: '', + streamThinking: '', + requestId: null, + isStreaming: false, + error: null, + }); + }, + + loadSession: (id: string, messages: ConversationMessage[]) => { + set({ + conversationId: id, + messages, + streamText: '', + streamThinking: '', + requestId: null, + isStreaming: false, + error: null, + }); + }, + + // Config setters + setSelectedModel: (id: string) => { + set({ selectedModel: id }); + }, + setSelectedAgent: (agent: ChatAgentInfo | null) => { + set({ selectedAgent: agent }); + }, + setSelectedTools: (ids: string[]) => { + set({ selectedTools: ids }); + }, + + // Load available options + loadModels: async () => { + try { + const models = await window.agentage.chat.getModels(); + set({ availableModels: models }); + + // Set default model if none selected + if (!get().selectedModel && models.length > 0) { + set({ selectedModel: models[0].id }); + } + } catch (err) { + console.error('Failed to load models:', err); + } + }, + + loadTools: async () => { + try { + const tools = await window.agentage.chat.getTools(); + set({ availableTools: tools }); + } catch (err) { + console.error('Failed to load tools:', err); + } + }, + + loadAgents: async () => { + try { + const agents = await window.agentage.chat.getAgents(); + set({ availableAgents: agents }); + } catch (err) { + console.error('Failed to load agents:', err); + } + }, + }), + { name: 'chat' } + ) +); /** - * Handle incoming chat events + * Handle incoming chat events - all events update the messages array */ const handleChatEvent = (event: ChatEvent): void => { const store = useChatStore.getState(); @@ -367,27 +268,147 @@ const handleChatEvent = (event: ChatEvent): void => { } switch (event.type) { - case 'text': - store.updateStreamText(event.text); + case 'text': { + // Accumulate streaming text + useChatStore.setState((state) => ({ + streamText: state.streamText + event.text, + })); break; - case 'thinking': - store.updateStreamThinking(event.text); + } + + case 'thinking': { + // Accumulate thinking text + useChatStore.setState((state) => ({ + streamThinking: state.streamThinking + event.text, + })); break; - case 'tool_call': - store.handleToolCall(event.toolCallId, event.name, event.input); + } + + case 'tool_call': { + // Add tool call as part of assistant message + const toolCallMessage: ConversationMessage = { + type: 'assistant', + id: generateId(), + content: '', + timestamp: new Date().toISOString(), + finishReason: 'tool_use', + tool_calls: [ + { + id: event.toolCallId, + name: event.name, + input: event.input, + status: 'running', + }, + ], + }; + + useChatStore.setState((state) => { + const messages = [...state.messages]; + const lastMsg = messages.at(-1); + + // If last message is assistant with tool_calls, add to it + if (lastMsg?.type === 'assistant' && lastMsg.tool_calls) { + messages[messages.length - 1] = { + ...lastMsg, + tool_calls: [ + ...lastMsg.tool_calls, + { + id: event.toolCallId, + name: event.name, + input: event.input, + status: 'running', + }, + ], + }; + return { messages }; + } + + // Otherwise add new assistant message with tool call + return { messages: [...messages, toolCallMessage] }; + }); break; - case 'tool_result': - store.handleToolResult(event.toolCallId, event.name, event.result, event.isError); + } + + case 'tool_result': { + // Add tool result message + const toolResultMessage: ConversationMessage = { + type: 'tool', + id: generateId(), + content: typeof event.result === 'string' ? event.result : JSON.stringify(event.result), + timestamp: new Date().toISOString(), + tool_call_id: event.toolCallId, + name: event.name, + isError: event.isError, + }; + + useChatStore.setState((state) => { + // Update tool call status to completed + const messages = state.messages.map((msg) => { + if (msg.type === 'assistant' && msg.tool_calls) { + const newStatus: 'completed' | 'error' = event.isError ? 'error' : 'completed'; + return { + ...msg, + tool_calls: msg.tool_calls.map((tc) => + tc.id === event.toolCallId ? { ...tc, status: newStatus } : tc + ), + }; + } + return msg; + }); + + return { messages: [...messages, toolResultMessage] }; + }); break; - case 'done': - store.handleDone(event.stopReason); + } + + case 'done': { + const { streamText, streamThinking } = useChatStore.getState(); + + // Finalize assistant message if we have streamed content + if (streamText || streamThinking) { + const assistantMessage: ConversationMessage = { + type: 'assistant', + id: generateId(), + content: streamText, + timestamp: new Date().toISOString(), + thinking: streamThinking || undefined, + finishReason: event.stopReason, + }; + + useChatStore.setState((state) => ({ + messages: [...state.messages, assistantMessage], + isStreaming: false, + streamText: '', + streamThinking: '', + requestId: null, + })); + } else { + useChatStore.setState({ + isStreaming: false, + requestId: null, + }); + } + + // Log non-standard stop reasons + if (event.stopReason !== 'end_turn') { + console.log('Chat stopped:', event.stopReason); + } break; - case 'error': - store.handleError(event.code, event.message); + } + + case 'error': { + useChatStore.setState({ + error: `${event.code}: ${event.message}`, + isStreaming: false, + requestId: null, + }); break; - case 'usage': + } + + case 'usage': { // Could track token usage if needed break; + } } }; diff --git a/src/renderer/stores/conversation.store.ts b/src/renderer/stores/conversation.store.ts index d49c1bb..e8c53f8 100644 --- a/src/renderer/stores/conversation.store.ts +++ b/src/renderer/stores/conversation.store.ts @@ -3,6 +3,7 @@ * Manages conversation history and persistence */ import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; import type { ConversationRef, ConversationSnapshot, @@ -48,90 +49,95 @@ interface ConversationState extends BaseSlices { getRecent: (limit?: number) => ConversationRef[]; } -export const useConversationStore = create((set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, +export const useConversationStore = create()( + devtools( + (set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, - // Data slices - conversations: [], - totalCount: 0, - currentSnapshot: null, + // Data slices + conversations: [], + totalCount: 0, + currentSnapshot: null, - // Actions - load: async (options?: ListConversationsOptions) => { - set({ isLoading: true, error: null }); - try { - const conversations = await window.agentage.conversations.list(options); - set({ - conversations, - totalCount: conversations.length, - isLoading: false, - }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + // Actions + load: async (options?: ListConversationsOptions) => { + set({ isLoading: true, error: null }); + try { + const conversations = await window.agentage.conversations.list(options); + set({ + conversations, + totalCount: conversations.length, + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - restore: async (id: string) => { - set({ isLoading: true, error: null }); - try { - const snapshot = await window.agentage.conversations.restore(id); - set({ isLoading: false }); - return snapshot; - } catch (err) { - set({ error: String(err), isLoading: false }); - return null; - } - }, + restore: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const snapshot = await window.agentage.conversations.restore(id); + set({ isLoading: false }); + return snapshot; + } catch (err) { + set({ error: String(err), isLoading: false }); + return null; + } + }, - delete: (id: string) => { - set({ isLoading: true, error: null }); - try { - // Note: Delete API might need to be added to preload - // For now, remove from local state - set((state) => ({ - conversations: state.conversations.filter((c) => c.id !== id), - totalCount: state.totalCount - 1, - isLoading: false, - })); - return Promise.resolve(true); - } catch (err) { - set({ error: String(err), isLoading: false }); - return Promise.resolve(false); - } - }, + delete: (id: string) => { + set({ isLoading: true, error: null }); + try { + // Note: Delete API might need to be added to preload + // For now, remove from local state + set((state) => ({ + conversations: state.conversations.filter((c) => c.id !== id), + totalCount: state.totalCount - 1, + isLoading: false, + })); + return Promise.resolve(true); + } catch (err) { + set({ error: String(err), isLoading: false }); + return Promise.resolve(false); + } + }, - deleteMultiple: (ids: string[]) => { - set({ isLoading: true, error: null }); - try { - // Remove from local state - set((state) => ({ - conversations: state.conversations.filter((c) => !ids.includes(c.id)), - totalCount: state.totalCount - ids.length, - isLoading: false, - })); - return Promise.resolve(true); - } catch (err) { - set({ error: String(err), isLoading: false }); - return Promise.resolve(false); - } - }, + deleteMultiple: (ids: string[]) => { + set({ isLoading: true, error: null }); + try { + // Remove from local state + set((state) => ({ + conversations: state.conversations.filter((c) => !ids.includes(c.id)), + totalCount: state.totalCount - ids.length, + isLoading: false, + })); + return Promise.resolve(true); + } catch (err) { + set({ error: String(err), isLoading: false }); + return Promise.resolve(false); + } + }, - // Helpers - getById: (id: string) => get().conversations.find((c) => c.id === id), - getPinned: () => get().conversations.filter((c) => c.isPinned), - getRecent: (limit = 10) => get().conversations.slice(0, limit), -})); + // Helpers + getById: (id: string) => get().conversations.find((c) => c.id === id), + getPinned: () => get().conversations.filter((c) => c.isPinned), + getRecent: (limit = 10) => get().conversations.slice(0, limit), + }), + { name: 'conversation' } + ) +); /** * Initialize conversation store event subscriptions diff --git a/src/renderer/stores/models.store.ts b/src/renderer/stores/models.store.ts index eef2ef9..799091d 100644 --- a/src/renderer/stores/models.store.ts +++ b/src/renderer/stores/models.store.ts @@ -3,6 +3,7 @@ * Manages LLM providers and their models */ import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; import type { ChatModelInfo, ModelInfo, @@ -52,155 +53,165 @@ interface ModelsState extends BaseSlices { setModelAsDefault: (provider: ModelProviderType, modelId: string) => Promise; } -export const useModelsStore = create((set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, - - // Data slices - providers: [], - chatModels: [], - defaultModelId: null, - isValidating: false, - - // Computed getters - enabledProviders: () => get().providers.filter((p) => p.enabled), - availableModels: () => { - const enabled = get().enabledProviders(); - return enabled.flatMap((p) => p.models.filter((m) => m.enabled)); - }, - getProviderByType: (type: ModelProviderType) => get().providers.find((p) => p.provider === type), - - // Provider actions - loadProviders: async (autoRefresh = false) => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.models.providers.load(autoRefresh); - set({ providers: result.providers, isLoading: false }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, - - saveProvider: async (config) => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.models.providers.save({ - provider: config.provider, - source: config.source, - token: config.token, - enabled: config.enabled, - models: config.models, - lastFetchedAt: new Date().toISOString(), - }); - - if (result.success) { - // Reload providers to get updated state - await get().loadProviders(); - return true; - } - set({ error: result.error ?? 'Failed to save provider', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, - - deleteProvider: async (provider: ModelProviderType) => { - set({ isLoading: true, error: null }); - try { - // Save provider with empty models and disabled state - const result = await window.agentage.models.providers.save({ - provider, - source: 'manual', - enabled: false, - models: [], - }); - - if (result.success) { - set((state) => ({ - providers: state.providers.filter((p) => p.provider !== provider), - isLoading: false, +export const useModelsStore = create()( + devtools( + (set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, + + // Data slices + providers: [], + chatModels: [], + defaultModelId: null, + isValidating: false, + + // Computed getters + enabledProviders: () => get().providers.filter((p) => p.enabled), + availableModels: () => { + const enabled = get().enabledProviders(); + return enabled.flatMap((p) => p.models.filter((m) => m.enabled)); + }, + getProviderByType: (type: ModelProviderType) => + get().providers.find((p) => p.provider === type), + + // Provider actions + loadProviders: async (autoRefresh = false) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.models.providers.load(autoRefresh); + set({ providers: result.providers, isLoading: false }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, + + saveProvider: async (config) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.models.providers.save({ + provider: config.provider, + source: config.source, + token: config.token, + enabled: config.enabled, + models: config.models, + lastFetchedAt: new Date().toISOString(), + }); + + if (result.success) { + // Reload providers to get updated state + await get().loadProviders(); + return true; + } + set({ error: result.error ?? 'Failed to save provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + deleteProvider: async (provider: ModelProviderType) => { + set({ isLoading: true, error: null }); + try { + // Save provider with empty models and disabled state + const result = await window.agentage.models.providers.save({ + provider, + source: 'manual', + enabled: false, + models: [], + }); + + if (result.success) { + set((state) => ({ + providers: state.providers.filter((p) => p.provider !== provider), + isLoading: false, + })); + return true; + } + set({ error: result.error ?? 'Failed to delete provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + // Model actions + validateToken: async (provider: ModelProviderType, token: string) => { + set({ isValidating: true, error: null }); + try { + const result = await window.agentage.models.validate({ provider, token }); + set({ isValidating: false }); + return { + valid: result.valid, + models: result.models, + error: result.error, + }; + } catch (err) { + set({ isValidating: false }); + return { valid: false, error: String(err) }; + } + }, + + loadChatModels: async () => { + try { + const models = await window.agentage.chat.getModels(); + set({ chatModels: models }); + } catch (err) { + console.error('Failed to load chat models:', err); + } + }, + + setDefaultModel: (id: string) => { + set({ defaultModelId: id }); + }, + + updateModelEnabled: async ( + provider: ModelProviderType, + modelId: string, + enabled: boolean + ) => { + const providerConfig = get().getProviderByType(provider); + if (!providerConfig) return false; + + const updatedModels = providerConfig.models.map((m) => + m.id === modelId ? { ...m, enabled } : m + ); + + return get().saveProvider({ + ...providerConfig, + models: updatedModels, + }); + }, + + setModelAsDefault: async (provider: ModelProviderType, modelId: string) => { + const providerConfig = get().getProviderByType(provider); + if (!providerConfig) return false; + + const updatedModels = providerConfig.models.map((m) => ({ + ...m, + isDefault: m.id === modelId, })); - return true; - } - set({ error: result.error ?? 'Failed to delete provider', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, - // Model actions - validateToken: async (provider: ModelProviderType, token: string) => { - set({ isValidating: true, error: null }); - try { - const result = await window.agentage.models.validate({ provider, token }); - set({ isValidating: false }); - return { - valid: result.valid, - models: result.models, - error: result.error, - }; - } catch (err) { - set({ isValidating: false }); - return { valid: false, error: String(err) }; - } - }, - - loadChatModels: async () => { - try { - const models = await window.agentage.chat.getModels(); - set({ chatModels: models }); - } catch (err) { - console.error('Failed to load chat models:', err); - } - }, - - setDefaultModel: (id: string) => { - set({ defaultModelId: id }); - }, - - updateModelEnabled: async (provider: ModelProviderType, modelId: string, enabled: boolean) => { - const providerConfig = get().getProviderByType(provider); - if (!providerConfig) return false; - - const updatedModels = providerConfig.models.map((m) => - m.id === modelId ? { ...m, enabled } : m - ); - - return get().saveProvider({ - ...providerConfig, - models: updatedModels, - }); - }, - - setModelAsDefault: async (provider: ModelProviderType, modelId: string) => { - const providerConfig = get().getProviderByType(provider); - if (!providerConfig) return false; - - const updatedModels = providerConfig.models.map((m) => ({ - ...m, - isDefault: m.id === modelId, - })); - - return get().saveProvider({ - ...providerConfig, - models: updatedModels, - }); - }, -})); + return get().saveProvider({ + ...providerConfig, + models: updatedModels, + }); + }, + }), + { name: 'models' } + ) +); /** * Initialize models store event subscriptions diff --git a/src/renderer/stores/tools.store.ts b/src/renderer/stores/tools.store.ts index 725106f..e275d06 100644 --- a/src/renderer/stores/tools.store.ts +++ b/src/renderer/stores/tools.store.ts @@ -3,6 +3,7 @@ * Manages tools and their enabled/disabled state */ import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; import type { ToolInfo } from '../../shared/index.js'; import type { BaseSlices, CleanupFn } from './types.js'; @@ -24,79 +25,84 @@ interface ToolsState extends BaseSlices { disableAll: () => Promise; } -export const useToolsStore = create((set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, +export const useToolsStore = create()( + devtools( + (set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, - // Data slices - tools: [], - enabledIds: [], + // Data slices + tools: [], + enabledIds: [], - // Computed getters - enabledTools: () => { - const { tools, enabledIds } = get(); - return tools.filter((t) => enabledIds.includes(t.name)); - }, - getToolByName: (name: string) => get().tools.find((t) => t.name === name), - isToolEnabled: (name: string) => get().enabledIds.includes(name), + // Computed getters + enabledTools: () => { + const { tools, enabledIds } = get(); + return tools.filter((t) => enabledIds.includes(t.name)); + }, + getToolByName: (name: string) => get().tools.find((t) => t.name === name), + isToolEnabled: (name: string) => get().enabledIds.includes(name), - // Actions - load: async () => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.tools.list(); - set({ - tools: result.tools, - enabledIds: result.settings.enabledTools, - isLoading: false, - }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + // Actions + load: async () => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.tools.list(); + set({ + tools: result.tools, + enabledIds: result.settings.enabledTools, + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - toggle: async (name: string) => { - const { enabledIds } = get(); - const newEnabled = enabledIds.includes(name) - ? enabledIds.filter((id) => id !== name) - : [...enabledIds, name]; + toggle: async (name: string) => { + const { enabledIds } = get(); + const newEnabled = enabledIds.includes(name) + ? enabledIds.filter((id) => id !== name) + : [...enabledIds, name]; - try { - await window.agentage.tools.updateSettings({ enabledTools: newEnabled }); - set({ enabledIds: newEnabled }); - } catch (err) { - set({ error: String(err) }); - } - }, + try { + await window.agentage.tools.updateSettings({ enabledTools: newEnabled }); + set({ enabledIds: newEnabled }); + } catch (err) { + set({ error: String(err) }); + } + }, - setEnabled: async (names: string[]) => { - try { - await window.agentage.tools.updateSettings({ enabledTools: names }); - set({ enabledIds: names }); - } catch (err) { - set({ error: String(err) }); - } - }, + setEnabled: async (names: string[]) => { + try { + await window.agentage.tools.updateSettings({ enabledTools: names }); + set({ enabledIds: names }); + } catch (err) { + set({ error: String(err) }); + } + }, - enableAll: async () => { - const allNames = get().tools.map((t) => t.name); - await get().setEnabled(allNames); - }, + enableAll: async () => { + const allNames = get().tools.map((t) => t.name); + await get().setEnabled(allNames); + }, - disableAll: async () => { - await get().setEnabled([]); - }, -})); + disableAll: async () => { + await get().setEnabled([]); + }, + }), + { name: 'tools' } + ) +); /** * Initialize tools store event subscriptions diff --git a/src/renderer/stores/workspace.store.ts b/src/renderer/stores/workspace.store.ts index e07b5b5..5821dc8 100644 --- a/src/renderer/stores/workspace.store.ts +++ b/src/renderer/stores/workspace.store.ts @@ -3,6 +3,7 @@ * Manages workspaces and active workspace selection */ import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; import type { Workspace, WorkspaceUpdate } from '../../shared/index.js'; import type { BaseSlices, CleanupFn } from './types.js'; @@ -25,123 +26,128 @@ interface WorkspaceState extends BaseSlices { save: (id: string, message?: string) => Promise; } -export const useWorkspaceStore = create((set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, +export const useWorkspaceStore = create()( + devtools( + (set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, - // Data slices - workspaces: [], - activeId: null, + // Data slices + workspaces: [], + activeId: null, - // Computed getters - active: () => { - const { workspaces, activeId } = get(); - return workspaces.find((w) => w.id === activeId) ?? null; - }, - getById: (id: string) => get().workspaces.find((w) => w.id === id), + // Computed getters + active: () => { + const { workspaces, activeId } = get(); + return workspaces.find((w) => w.id === activeId) ?? null; + }, + getById: (id: string) => get().workspaces.find((w) => w.id === id), - // Actions - load: async () => { - set({ isLoading: true, error: null }); - try { - const [workspaces, active] = await Promise.all([ - window.agentage.workspace.list(), - window.agentage.workspace.getActive(), - ]); - set({ - workspaces, - activeId: active?.id ?? null, - isLoading: false, - }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + // Actions + load: async () => { + set({ isLoading: true, error: null }); + try { + const [workspaces, active] = await Promise.all([ + window.agentage.workspace.list(), + window.agentage.workspace.getActive(), + ]); + set({ + workspaces, + activeId: active?.id ?? null, + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - select: async (id: string) => { - set({ isLoading: true, error: null }); - try { - await window.agentage.workspace.switch(id); - set({ activeId: id, isLoading: false }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + select: async (id: string) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.switch(id); + set({ activeId: id, isLoading: false }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - add: async (path: string) => { - set({ isLoading: true, error: null }); - try { - const id = await window.agentage.workspace.add(path); - // Reload to get full workspace data with git status - await get().load(); - return id; - } catch (err) { - set({ error: String(err), isLoading: false }); - return null; - } - }, + add: async (path: string) => { + set({ isLoading: true, error: null }); + try { + const id = await window.agentage.workspace.add(path); + // Reload to get full workspace data with git status + await get().load(); + return id; + } catch (err) { + set({ error: String(err), isLoading: false }); + return null; + } + }, - remove: async (id: string) => { - set({ isLoading: true, error: null }); - try { - await window.agentage.workspace.remove(id); - set((state) => ({ - workspaces: state.workspaces.filter((w) => w.id !== id), - activeId: state.activeId === id ? null : state.activeId, - isLoading: false, - })); - return true; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + remove: async (id: string) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.remove(id); + set((state) => ({ + workspaces: state.workspaces.filter((w) => w.id !== id), + activeId: state.activeId === id ? null : state.activeId, + isLoading: false, + })); + return true; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - update: async (id: string, updates: WorkspaceUpdate) => { - set({ isLoading: true, error: null }); - try { - await window.agentage.workspace.update(id, updates); - set((state) => ({ - workspaces: state.workspaces.map((w) => (w.id === id ? { ...w, ...updates } : w)), - isLoading: false, - })); - return true; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + update: async (id: string, updates: WorkspaceUpdate) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.update(id, updates); + set((state) => ({ + workspaces: state.workspaces.map((w) => (w.id === id ? { ...w, ...updates } : w)), + isLoading: false, + })); + return true; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - browse: async () => { - try { - return await window.agentage.workspace.browse(); - } catch (err) { - set({ error: String(err) }); - return undefined; - } - }, + browse: async () => { + try { + return await window.agentage.workspace.browse(); + } catch (err) { + set({ error: String(err) }); + return undefined; + } + }, - save: async (id: string, message?: string) => { - set({ isLoading: true, error: null }); - try { - await window.agentage.workspace.save(id, message); - // Reload to get updated git status - await get().load(); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, -})); + save: async (id: string, message?: string) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.save(id, message); + // Reload to get updated git status + await get().load(); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, + }), + { name: 'workspace' } + ) +); /** * Initialize workspace store event subscriptions From 5cc45195980418e4b23254b6bfd8e45ec7fd57f6 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Tue, 13 Jan 2026 00:02:32 +0100 Subject: [PATCH 3/3] chore: remove devtools middleware from stores --- src/renderer/stores/auth.store.ts | 236 +++++++-------- src/renderer/stores/chat.store.ts | 350 +++++++++++----------- src/renderer/stores/conversation.store.ts | 162 +++++----- src/renderer/stores/models.store.ts | 305 +++++++++---------- src/renderer/stores/tools.store.ts | 136 ++++----- src/renderer/stores/workspace.store.ts | 222 +++++++------- 6 files changed, 685 insertions(+), 726 deletions(-) diff --git a/src/renderer/stores/auth.store.ts b/src/renderer/stores/auth.store.ts index 5b674ae..404bd0c 100644 --- a/src/renderer/stores/auth.store.ts +++ b/src/renderer/stores/auth.store.ts @@ -3,7 +3,6 @@ * Manages user authentication state and GitHub connections */ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; import type { LinkedProvider, OAuthProvider, User } from '../../shared/index.js'; import type { BaseSlices, CleanupFn } from './types.js'; @@ -25,134 +24,129 @@ interface AuthState extends BaseSlices { loadProviders: () => Promise; } -export const useAuthStore = create()( - devtools( - (set, get) => ({ - // Base slices - isLoading: true, // Start loading to check auth on init - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, +export const useAuthStore = create()((set, get) => ({ + // Base slices + isLoading: true, // Start loading to check auth on init + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, - // Data slices - user: null, - provider: null, - linkedProviders: [], + // Data slices + user: null, + provider: null, + linkedProviders: [], - // Computed - isAuthenticated: () => get().user !== null, + // Computed + isAuthenticated: () => get().user !== null, - // Actions - login: async () => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.auth.login(); - if (result.success && result.user) { - set({ - user: result.user, - isLoading: false, - }); - // Load linked providers after login - await get().loadProviders(); - return true; - } - set({ error: result.error ?? 'Login failed', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + // Actions + login: async () => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.auth.login(); + if (result.success && result.user) { + set({ + user: result.user, + isLoading: false, + }); + // Load linked providers after login + await get().loadProviders(); + return true; + } + set({ error: result.error ?? 'Login failed', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - logout: async () => { - set({ isLoading: true, error: null }); - try { - await window.agentage.auth.logout(); - set({ - user: null, - provider: null, - linkedProviders: [], - isLoading: false, - }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + logout: async () => { + set({ isLoading: true, error: null }); + try { + await window.agentage.auth.logout(); + set({ + user: null, + provider: null, + linkedProviders: [], + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - checkAuth: async () => { - set({ isLoading: true, error: null }); - try { - const user = await window.agentage.auth.getUser(); - if (user) { - set({ user, isLoading: false }); - // Load linked providers if authenticated - await get().loadProviders(); - } else { - set({ user: null, isLoading: false }); - } - } catch (err) { - set({ error: String(err), user: null, isLoading: false }); - } - }, + checkAuth: async () => { + set({ isLoading: true, error: null }); + try { + const user = await window.agentage.auth.getUser(); + if (user) { + set({ user, isLoading: false }); + // Load linked providers if authenticated + await get().loadProviders(); + } else { + set({ user: null, isLoading: false }); + } + } catch (err) { + set({ error: String(err), user: null, isLoading: false }); + } + }, - linkProvider: async (provider: OAuthProvider) => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.auth.linkProvider(provider); - if (result.success && result.provider) { - const linkedProvider = result.provider; - set((state) => ({ - linkedProviders: [...state.linkedProviders, linkedProvider], - isLoading: false, - })); - return true; - } - set({ error: result.error ?? 'Failed to link provider', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + linkProvider: async (provider: OAuthProvider) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.auth.linkProvider(provider); + if (result.success && result.provider) { + const linkedProvider = result.provider; + set((state) => ({ + linkedProviders: [...state.linkedProviders, linkedProvider], + isLoading: false, + })); + return true; + } + set({ error: result.error ?? 'Failed to link provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - unlinkProvider: async (provider: OAuthProvider) => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.auth.unlinkProvider(provider); - if (result.success) { - set((state) => ({ - linkedProviders: state.linkedProviders.filter((p) => p.name !== provider), - isLoading: false, - })); - return true; - } - set({ error: result.error ?? 'Failed to unlink provider', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + unlinkProvider: async (provider: OAuthProvider) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.auth.unlinkProvider(provider); + if (result.success) { + set((state) => ({ + linkedProviders: state.linkedProviders.filter((p) => p.name !== provider), + isLoading: false, + })); + return true; + } + set({ error: result.error ?? 'Failed to unlink provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - loadProviders: async () => { - try { - const providers = await window.agentage.auth.getProviders(); - set({ linkedProviders: providers }); - } catch (err) { - console.error('Failed to load linked providers:', err); - } - }, - }), - { name: 'auth' } - ) -); + loadProviders: async () => { + try { + const providers = await window.agentage.auth.getProviders(); + set({ linkedProviders: providers }); + } catch (err) { + console.error('Failed to load linked providers:', err); + } + }, +})); /** * Initialize auth store event subscriptions diff --git a/src/renderer/stores/chat.store.ts b/src/renderer/stores/chat.store.ts index 8fc38f0..b7de309 100644 --- a/src/renderer/stores/chat.store.ts +++ b/src/renderer/stores/chat.store.ts @@ -8,7 +8,6 @@ * - ToolMessage (type: 'tool') - tool execution results */ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; import type { ChatAgentInfo, ChatEvent, @@ -66,195 +65,190 @@ interface ChatState extends BaseSlices { loadAgents: () => Promise; } -export const useChatStore = create()( - devtools( - (set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, - - // Session - conversationId: null, - messages: [], +export const useChatStore = create()((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, - // Streaming state - isStreaming: false, - streamText: '', - streamThinking: '', - requestId: null, + // Session + conversationId: null, + messages: [], - // Selected config - selectedModel: null, - selectedAgent: null, - selectedTools: [], + // Streaming state + isStreaming: false, + streamText: '', + streamThinking: '', + requestId: null, - // Available options - availableModels: [], - availableTools: [], - availableAgents: [], + // Selected config + selectedModel: null, + selectedAgent: null, + selectedTools: [], - // Actions - send: async (content: string, references?: ChatReference[]) => { - const { selectedModel, selectedAgent, selectedTools, conversationId } = get(); + // Available options + availableModels: [], + availableTools: [], + availableAgents: [], - if (!selectedModel) { - set({ error: 'No model selected' }); - return; - } + // Actions + send: async (content: string, references?: ChatReference[]) => { + const { selectedModel, selectedAgent, selectedTools, conversationId } = get(); - // Create user message - const userMessage: ConversationMessage = { - type: 'user', - id: generateId(), - content, - timestamp: new Date().toISOString(), - references: references?.map((ref) => ({ - type: ref.type, - uri: ref.uri, - content: ref.content, - range: ref.range, - })), - }; + if (!selectedModel) { + set({ error: 'No model selected' }); + return; + } - set((state) => ({ - messages: [...state.messages, userMessage], - isStreaming: true, - streamText: '', - streamThinking: '', - error: null, - })); + // Create user message + const userMessage: ConversationMessage = { + type: 'user', + id: generateId(), + content, + timestamp: new Date().toISOString(), + references: references?.map((ref) => ({ + type: ref.type, + uri: ref.uri, + content: ref.content, + range: ref.range, + })), + }; + + set((state) => ({ + messages: [...state.messages, userMessage], + isStreaming: true, + streamText: '', + streamThinking: '', + error: null, + })); + + try { + const config: SessionConfig = { + conversationId: conversationId ?? undefined, + model: selectedModel, + agent: selectedAgent?.id, + tools: selectedTools.length > 0 ? selectedTools : undefined, + }; - try { - const config: SessionConfig = { - conversationId: conversationId ?? undefined, - model: selectedModel, - agent: selectedAgent?.id, - tools: selectedTools.length > 0 ? selectedTools : undefined, - }; + const response = await window.agentage.chat.send({ + prompt: content, + references, + config, + }); - const response = await window.agentage.chat.send({ - prompt: content, - references, - config, - }); - - set({ - requestId: response.requestId, - conversationId: response.conversationId, - }); - } catch (err) { - set({ - error: String(err), - isStreaming: false, - }); - } - }, - - cancel: () => { - const { requestId } = get(); - if (requestId) { - window.agentage.chat.cancel(requestId); - set({ - isStreaming: false, - requestId: null, - }); - } - }, + set({ + requestId: response.requestId, + conversationId: response.conversationId, + }); + } catch (err) { + set({ + error: String(err), + isStreaming: false, + }); + } + }, - clear: () => { - window.agentage.chat.clear(); - set({ - conversationId: null, - messages: [], - streamText: '', - streamThinking: '', - requestId: null, - isStreaming: false, - error: null, - }); - }, + cancel: () => { + const { requestId } = get(); + if (requestId) { + window.agentage.chat.cancel(requestId); + set({ + isStreaming: false, + requestId: null, + }); + } + }, - // Session management - newSession: () => { - set({ - conversationId: null, - messages: [], - streamText: '', - streamThinking: '', - requestId: null, - isStreaming: false, - error: null, - }); - }, + clear: () => { + window.agentage.chat.clear(); + set({ + conversationId: null, + messages: [], + streamText: '', + streamThinking: '', + requestId: null, + isStreaming: false, + error: null, + }); + }, - loadSession: (id: string, messages: ConversationMessage[]) => { - set({ - conversationId: id, - messages, - streamText: '', - streamThinking: '', - requestId: null, - isStreaming: false, - error: null, - }); - }, - - // Config setters - setSelectedModel: (id: string) => { - set({ selectedModel: id }); - }, - setSelectedAgent: (agent: ChatAgentInfo | null) => { - set({ selectedAgent: agent }); - }, - setSelectedTools: (ids: string[]) => { - set({ selectedTools: ids }); - }, - - // Load available options - loadModels: async () => { - try { - const models = await window.agentage.chat.getModels(); - set({ availableModels: models }); - - // Set default model if none selected - if (!get().selectedModel && models.length > 0) { - set({ selectedModel: models[0].id }); - } - } catch (err) { - console.error('Failed to load models:', err); - } - }, - - loadTools: async () => { - try { - const tools = await window.agentage.chat.getTools(); - set({ availableTools: tools }); - } catch (err) { - console.error('Failed to load tools:', err); - } - }, - - loadAgents: async () => { - try { - const agents = await window.agentage.chat.getAgents(); - set({ availableAgents: agents }); - } catch (err) { - console.error('Failed to load agents:', err); - } - }, - }), - { name: 'chat' } - ) -); + // Session management + newSession: () => { + set({ + conversationId: null, + messages: [], + streamText: '', + streamThinking: '', + requestId: null, + isStreaming: false, + error: null, + }); + }, + + loadSession: (id: string, messages: ConversationMessage[]) => { + set({ + conversationId: id, + messages, + streamText: '', + streamThinking: '', + requestId: null, + isStreaming: false, + error: null, + }); + }, + + // Config setters + setSelectedModel: (id: string) => { + set({ selectedModel: id }); + }, + setSelectedAgent: (agent: ChatAgentInfo | null) => { + set({ selectedAgent: agent }); + }, + setSelectedTools: (ids: string[]) => { + set({ selectedTools: ids }); + }, + + // Load available options + loadModels: async () => { + try { + const models = await window.agentage.chat.getModels(); + set({ availableModels: models }); + + // Set default model if none selected + if (!get().selectedModel && models.length > 0) { + set({ selectedModel: models[0].id }); + } + } catch (err) { + console.error('Failed to load models:', err); + } + }, + + loadTools: async () => { + try { + const tools = await window.agentage.chat.getTools(); + set({ availableTools: tools }); + } catch (err) { + console.error('Failed to load tools:', err); + } + }, + + loadAgents: async () => { + try { + const agents = await window.agentage.chat.getAgents(); + set({ availableAgents: agents }); + } catch (err) { + console.error('Failed to load agents:', err); + } + }, +})); /** * Handle incoming chat events - all events update the messages array diff --git a/src/renderer/stores/conversation.store.ts b/src/renderer/stores/conversation.store.ts index e8c53f8..3731565 100644 --- a/src/renderer/stores/conversation.store.ts +++ b/src/renderer/stores/conversation.store.ts @@ -3,7 +3,6 @@ * Manages conversation history and persistence */ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; import type { ConversationRef, ConversationSnapshot, @@ -49,95 +48,90 @@ interface ConversationState extends BaseSlices { getRecent: (limit?: number) => ConversationRef[]; } -export const useConversationStore = create()( - devtools( - (set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, +export const useConversationStore = create()((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, - // Data slices - conversations: [], - totalCount: 0, - currentSnapshot: null, + // Data slices + conversations: [], + totalCount: 0, + currentSnapshot: null, - // Actions - load: async (options?: ListConversationsOptions) => { - set({ isLoading: true, error: null }); - try { - const conversations = await window.agentage.conversations.list(options); - set({ - conversations, - totalCount: conversations.length, - isLoading: false, - }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + // Actions + load: async (options?: ListConversationsOptions) => { + set({ isLoading: true, error: null }); + try { + const conversations = await window.agentage.conversations.list(options); + set({ + conversations, + totalCount: conversations.length, + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - restore: async (id: string) => { - set({ isLoading: true, error: null }); - try { - const snapshot = await window.agentage.conversations.restore(id); - set({ isLoading: false }); - return snapshot; - } catch (err) { - set({ error: String(err), isLoading: false }); - return null; - } - }, + restore: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const snapshot = await window.agentage.conversations.restore(id); + set({ isLoading: false }); + return snapshot; + } catch (err) { + set({ error: String(err), isLoading: false }); + return null; + } + }, - delete: (id: string) => { - set({ isLoading: true, error: null }); - try { - // Note: Delete API might need to be added to preload - // For now, remove from local state - set((state) => ({ - conversations: state.conversations.filter((c) => c.id !== id), - totalCount: state.totalCount - 1, - isLoading: false, - })); - return Promise.resolve(true); - } catch (err) { - set({ error: String(err), isLoading: false }); - return Promise.resolve(false); - } - }, + delete: (id: string) => { + set({ isLoading: true, error: null }); + try { + // Note: Delete API might need to be added to preload + // For now, remove from local state + set((state) => ({ + conversations: state.conversations.filter((c) => c.id !== id), + totalCount: state.totalCount - 1, + isLoading: false, + })); + return Promise.resolve(true); + } catch (err) { + set({ error: String(err), isLoading: false }); + return Promise.resolve(false); + } + }, - deleteMultiple: (ids: string[]) => { - set({ isLoading: true, error: null }); - try { - // Remove from local state - set((state) => ({ - conversations: state.conversations.filter((c) => !ids.includes(c.id)), - totalCount: state.totalCount - ids.length, - isLoading: false, - })); - return Promise.resolve(true); - } catch (err) { - set({ error: String(err), isLoading: false }); - return Promise.resolve(false); - } - }, + deleteMultiple: (ids: string[]) => { + set({ isLoading: true, error: null }); + try { + // Remove from local state + set((state) => ({ + conversations: state.conversations.filter((c) => !ids.includes(c.id)), + totalCount: state.totalCount - ids.length, + isLoading: false, + })); + return Promise.resolve(true); + } catch (err) { + set({ error: String(err), isLoading: false }); + return Promise.resolve(false); + } + }, - // Helpers - getById: (id: string) => get().conversations.find((c) => c.id === id), - getPinned: () => get().conversations.filter((c) => c.isPinned), - getRecent: (limit = 10) => get().conversations.slice(0, limit), - }), - { name: 'conversation' } - ) -); + // Helpers + getById: (id: string) => get().conversations.find((c) => c.id === id), + getPinned: () => get().conversations.filter((c) => c.isPinned), + getRecent: (limit = 10) => get().conversations.slice(0, limit), +})); /** * Initialize conversation store event subscriptions diff --git a/src/renderer/stores/models.store.ts b/src/renderer/stores/models.store.ts index 799091d..e68ea21 100644 --- a/src/renderer/stores/models.store.ts +++ b/src/renderer/stores/models.store.ts @@ -3,7 +3,6 @@ * Manages LLM providers and their models */ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; import type { ChatModelInfo, ModelInfo, @@ -53,165 +52,155 @@ interface ModelsState extends BaseSlices { setModelAsDefault: (provider: ModelProviderType, modelId: string) => Promise; } -export const useModelsStore = create()( - devtools( - (set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, - - // Data slices - providers: [], - chatModels: [], - defaultModelId: null, - isValidating: false, - - // Computed getters - enabledProviders: () => get().providers.filter((p) => p.enabled), - availableModels: () => { - const enabled = get().enabledProviders(); - return enabled.flatMap((p) => p.models.filter((m) => m.enabled)); - }, - getProviderByType: (type: ModelProviderType) => - get().providers.find((p) => p.provider === type), - - // Provider actions - loadProviders: async (autoRefresh = false) => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.models.providers.load(autoRefresh); - set({ providers: result.providers, isLoading: false }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, - - saveProvider: async (config) => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.models.providers.save({ - provider: config.provider, - source: config.source, - token: config.token, - enabled: config.enabled, - models: config.models, - lastFetchedAt: new Date().toISOString(), - }); - - if (result.success) { - // Reload providers to get updated state - await get().loadProviders(); - return true; - } - set({ error: result.error ?? 'Failed to save provider', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, - - deleteProvider: async (provider: ModelProviderType) => { - set({ isLoading: true, error: null }); - try { - // Save provider with empty models and disabled state - const result = await window.agentage.models.providers.save({ - provider, - source: 'manual', - enabled: false, - models: [], - }); - - if (result.success) { - set((state) => ({ - providers: state.providers.filter((p) => p.provider !== provider), - isLoading: false, - })); - return true; - } - set({ error: result.error ?? 'Failed to delete provider', isLoading: false }); - return false; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, - - // Model actions - validateToken: async (provider: ModelProviderType, token: string) => { - set({ isValidating: true, error: null }); - try { - const result = await window.agentage.models.validate({ provider, token }); - set({ isValidating: false }); - return { - valid: result.valid, - models: result.models, - error: result.error, - }; - } catch (err) { - set({ isValidating: false }); - return { valid: false, error: String(err) }; - } - }, - - loadChatModels: async () => { - try { - const models = await window.agentage.chat.getModels(); - set({ chatModels: models }); - } catch (err) { - console.error('Failed to load chat models:', err); - } - }, - - setDefaultModel: (id: string) => { - set({ defaultModelId: id }); - }, - - updateModelEnabled: async ( - provider: ModelProviderType, - modelId: string, - enabled: boolean - ) => { - const providerConfig = get().getProviderByType(provider); - if (!providerConfig) return false; - - const updatedModels = providerConfig.models.map((m) => - m.id === modelId ? { ...m, enabled } : m - ); - - return get().saveProvider({ - ...providerConfig, - models: updatedModels, - }); - }, - - setModelAsDefault: async (provider: ModelProviderType, modelId: string) => { - const providerConfig = get().getProviderByType(provider); - if (!providerConfig) return false; - - const updatedModels = providerConfig.models.map((m) => ({ - ...m, - isDefault: m.id === modelId, +export const useModelsStore = create()((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, + + // Data slices + providers: [], + chatModels: [], + defaultModelId: null, + isValidating: false, + + // Computed getters + enabledProviders: () => get().providers.filter((p) => p.enabled), + availableModels: () => { + const enabled = get().enabledProviders(); + return enabled.flatMap((p) => p.models.filter((m) => m.enabled)); + }, + getProviderByType: (type: ModelProviderType) => get().providers.find((p) => p.provider === type), + + // Provider actions + loadProviders: async (autoRefresh = false) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.models.providers.load(autoRefresh); + set({ providers: result.providers, isLoading: false }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, + + saveProvider: async (config) => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.models.providers.save({ + provider: config.provider, + source: config.source, + token: config.token, + enabled: config.enabled, + models: config.models, + lastFetchedAt: new Date().toISOString(), + }); + + if (result.success) { + // Reload providers to get updated state + await get().loadProviders(); + return true; + } + set({ error: result.error ?? 'Failed to save provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, + + deleteProvider: async (provider: ModelProviderType) => { + set({ isLoading: true, error: null }); + try { + // Save provider with empty models and disabled state + const result = await window.agentage.models.providers.save({ + provider, + source: 'manual', + enabled: false, + models: [], + }); + + if (result.success) { + set((state) => ({ + providers: state.providers.filter((p) => p.provider !== provider), + isLoading: false, })); + return true; + } + set({ error: result.error ?? 'Failed to delete provider', isLoading: false }); + return false; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - return get().saveProvider({ - ...providerConfig, - models: updatedModels, - }); - }, - }), - { name: 'models' } - ) -); + // Model actions + validateToken: async (provider: ModelProviderType, token: string) => { + set({ isValidating: true, error: null }); + try { + const result = await window.agentage.models.validate({ provider, token }); + set({ isValidating: false }); + return { + valid: result.valid, + models: result.models, + error: result.error, + }; + } catch (err) { + set({ isValidating: false }); + return { valid: false, error: String(err) }; + } + }, + + loadChatModels: async () => { + try { + const models = await window.agentage.chat.getModels(); + set({ chatModels: models }); + } catch (err) { + console.error('Failed to load chat models:', err); + } + }, + + setDefaultModel: (id: string) => { + set({ defaultModelId: id }); + }, + + updateModelEnabled: async (provider: ModelProviderType, modelId: string, enabled: boolean) => { + const providerConfig = get().getProviderByType(provider); + if (!providerConfig) return false; + + const updatedModels = providerConfig.models.map((m) => + m.id === modelId ? { ...m, enabled } : m + ); + + return get().saveProvider({ + ...providerConfig, + models: updatedModels, + }); + }, + + setModelAsDefault: async (provider: ModelProviderType, modelId: string) => { + const providerConfig = get().getProviderByType(provider); + if (!providerConfig) return false; + + const updatedModels = providerConfig.models.map((m) => ({ + ...m, + isDefault: m.id === modelId, + })); + + return get().saveProvider({ + ...providerConfig, + models: updatedModels, + }); + }, +})); /** * Initialize models store event subscriptions diff --git a/src/renderer/stores/tools.store.ts b/src/renderer/stores/tools.store.ts index e275d06..43c6dc0 100644 --- a/src/renderer/stores/tools.store.ts +++ b/src/renderer/stores/tools.store.ts @@ -3,7 +3,6 @@ * Manages tools and their enabled/disabled state */ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; import type { ToolInfo } from '../../shared/index.js'; import type { BaseSlices, CleanupFn } from './types.js'; @@ -25,84 +24,79 @@ interface ToolsState extends BaseSlices { disableAll: () => Promise; } -export const useToolsStore = create()( - devtools( - (set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, +export const useToolsStore = create()((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, - // Data slices - tools: [], - enabledIds: [], + // Data slices + tools: [], + enabledIds: [], - // Computed getters - enabledTools: () => { - const { tools, enabledIds } = get(); - return tools.filter((t) => enabledIds.includes(t.name)); - }, - getToolByName: (name: string) => get().tools.find((t) => t.name === name), - isToolEnabled: (name: string) => get().enabledIds.includes(name), + // Computed getters + enabledTools: () => { + const { tools, enabledIds } = get(); + return tools.filter((t) => enabledIds.includes(t.name)); + }, + getToolByName: (name: string) => get().tools.find((t) => t.name === name), + isToolEnabled: (name: string) => get().enabledIds.includes(name), - // Actions - load: async () => { - set({ isLoading: true, error: null }); - try { - const result = await window.agentage.tools.list(); - set({ - tools: result.tools, - enabledIds: result.settings.enabledTools, - isLoading: false, - }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + // Actions + load: async () => { + set({ isLoading: true, error: null }); + try { + const result = await window.agentage.tools.list(); + set({ + tools: result.tools, + enabledIds: result.settings.enabledTools, + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - toggle: async (name: string) => { - const { enabledIds } = get(); - const newEnabled = enabledIds.includes(name) - ? enabledIds.filter((id) => id !== name) - : [...enabledIds, name]; + toggle: async (name: string) => { + const { enabledIds } = get(); + const newEnabled = enabledIds.includes(name) + ? enabledIds.filter((id) => id !== name) + : [...enabledIds, name]; - try { - await window.agentage.tools.updateSettings({ enabledTools: newEnabled }); - set({ enabledIds: newEnabled }); - } catch (err) { - set({ error: String(err) }); - } - }, + try { + await window.agentage.tools.updateSettings({ enabledTools: newEnabled }); + set({ enabledIds: newEnabled }); + } catch (err) { + set({ error: String(err) }); + } + }, - setEnabled: async (names: string[]) => { - try { - await window.agentage.tools.updateSettings({ enabledTools: names }); - set({ enabledIds: names }); - } catch (err) { - set({ error: String(err) }); - } - }, + setEnabled: async (names: string[]) => { + try { + await window.agentage.tools.updateSettings({ enabledTools: names }); + set({ enabledIds: names }); + } catch (err) { + set({ error: String(err) }); + } + }, - enableAll: async () => { - const allNames = get().tools.map((t) => t.name); - await get().setEnabled(allNames); - }, + enableAll: async () => { + const allNames = get().tools.map((t) => t.name); + await get().setEnabled(allNames); + }, - disableAll: async () => { - await get().setEnabled([]); - }, - }), - { name: 'tools' } - ) -); + disableAll: async () => { + await get().setEnabled([]); + }, +})); /** * Initialize tools store event subscriptions diff --git a/src/renderer/stores/workspace.store.ts b/src/renderer/stores/workspace.store.ts index 5821dc8..a750545 100644 --- a/src/renderer/stores/workspace.store.ts +++ b/src/renderer/stores/workspace.store.ts @@ -3,7 +3,6 @@ * Manages workspaces and active workspace selection */ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; import type { Workspace, WorkspaceUpdate } from '../../shared/index.js'; import type { BaseSlices, CleanupFn } from './types.js'; @@ -26,128 +25,123 @@ interface WorkspaceState extends BaseSlices { save: (id: string, message?: string) => Promise; } -export const useWorkspaceStore = create()( - devtools( - (set, get) => ({ - // Base slices - isLoading: false, - error: null, - setLoading: (isLoading) => { - set({ isLoading }); - }, - setError: (error) => { - set({ error }); - }, - clearError: () => { - set({ error: null }); - }, +export const useWorkspaceStore = create()((set, get) => ({ + // Base slices + isLoading: false, + error: null, + setLoading: (isLoading) => { + set({ isLoading }); + }, + setError: (error) => { + set({ error }); + }, + clearError: () => { + set({ error: null }); + }, - // Data slices - workspaces: [], - activeId: null, + // Data slices + workspaces: [], + activeId: null, - // Computed getters - active: () => { - const { workspaces, activeId } = get(); - return workspaces.find((w) => w.id === activeId) ?? null; - }, - getById: (id: string) => get().workspaces.find((w) => w.id === id), + // Computed getters + active: () => { + const { workspaces, activeId } = get(); + return workspaces.find((w) => w.id === activeId) ?? null; + }, + getById: (id: string) => get().workspaces.find((w) => w.id === id), - // Actions - load: async () => { - set({ isLoading: true, error: null }); - try { - const [workspaces, active] = await Promise.all([ - window.agentage.workspace.list(), - window.agentage.workspace.getActive(), - ]); - set({ - workspaces, - activeId: active?.id ?? null, - isLoading: false, - }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + // Actions + load: async () => { + set({ isLoading: true, error: null }); + try { + const [workspaces, active] = await Promise.all([ + window.agentage.workspace.list(), + window.agentage.workspace.getActive(), + ]); + set({ + workspaces, + activeId: active?.id ?? null, + isLoading: false, + }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - select: async (id: string) => { - set({ isLoading: true, error: null }); - try { - await window.agentage.workspace.switch(id); - set({ activeId: id, isLoading: false }); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, + select: async (id: string) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.switch(id); + set({ activeId: id, isLoading: false }); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, - add: async (path: string) => { - set({ isLoading: true, error: null }); - try { - const id = await window.agentage.workspace.add(path); - // Reload to get full workspace data with git status - await get().load(); - return id; - } catch (err) { - set({ error: String(err), isLoading: false }); - return null; - } - }, + add: async (path: string) => { + set({ isLoading: true, error: null }); + try { + const id = await window.agentage.workspace.add(path); + // Reload to get full workspace data with git status + await get().load(); + return id; + } catch (err) { + set({ error: String(err), isLoading: false }); + return null; + } + }, - remove: async (id: string) => { - set({ isLoading: true, error: null }); - try { - await window.agentage.workspace.remove(id); - set((state) => ({ - workspaces: state.workspaces.filter((w) => w.id !== id), - activeId: state.activeId === id ? null : state.activeId, - isLoading: false, - })); - return true; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + remove: async (id: string) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.remove(id); + set((state) => ({ + workspaces: state.workspaces.filter((w) => w.id !== id), + activeId: state.activeId === id ? null : state.activeId, + isLoading: false, + })); + return true; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - update: async (id: string, updates: WorkspaceUpdate) => { - set({ isLoading: true, error: null }); - try { - await window.agentage.workspace.update(id, updates); - set((state) => ({ - workspaces: state.workspaces.map((w) => (w.id === id ? { ...w, ...updates } : w)), - isLoading: false, - })); - return true; - } catch (err) { - set({ error: String(err), isLoading: false }); - return false; - } - }, + update: async (id: string, updates: WorkspaceUpdate) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.update(id, updates); + set((state) => ({ + workspaces: state.workspaces.map((w) => (w.id === id ? { ...w, ...updates } : w)), + isLoading: false, + })); + return true; + } catch (err) { + set({ error: String(err), isLoading: false }); + return false; + } + }, - browse: async () => { - try { - return await window.agentage.workspace.browse(); - } catch (err) { - set({ error: String(err) }); - return undefined; - } - }, + browse: async () => { + try { + return await window.agentage.workspace.browse(); + } catch (err) { + set({ error: String(err) }); + return undefined; + } + }, - save: async (id: string, message?: string) => { - set({ isLoading: true, error: null }); - try { - await window.agentage.workspace.save(id, message); - // Reload to get updated git status - await get().load(); - } catch (err) { - set({ error: String(err), isLoading: false }); - } - }, - }), - { name: 'workspace' } - ) -); + save: async (id: string, message?: string) => { + set({ isLoading: true, error: null }); + try { + await window.agentage.workspace.save(id, message); + // Reload to get updated git status + await get().load(); + } catch (err) { + set({ error: String(err), isLoading: false }); + } + }, +})); /** * Initialize workspace store event subscriptions