diff --git a/package-lock.json b/package-lock.json index f8cce75..11f0441 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", @@ -42,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", @@ -6788,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", @@ -8299,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", @@ -8513,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", @@ -9453,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", @@ -9544,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", @@ -10754,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", @@ -11081,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", @@ -11610,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", @@ -12556,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", @@ -12911,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", @@ -13031,6 +13150,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..32f1c84 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", @@ -67,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/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/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 new file mode 100644 index 0000000..404bd0c --- /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..b7de309 --- /dev/null +++ b/src/renderer/stores/chat.store.ts @@ -0,0 +1,423 @@ +/** + * 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 type { + ChatAgentInfo, + ChatEvent, + ChatModelInfo, + ChatReference, + ChatToolInfo, + ConversationMessage, + SessionConfig, +} 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: ConversationMessage[]; + + // Streaming state + isStreaming: boolean; + streamText: string; + streamThinking: string; + requestId: string | null; + + // Selected config (user's current selection) + selectedModel: string | null; + selectedAgent: ChatAgentInfo | null; + selectedTools: string[]; + + // Available options (cached from backend) + 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: ConversationMessage[]) => void; + + // Config setters + setSelectedModel: (id: string) => void; + setSelectedAgent: (agent: ChatAgentInfo | null) => void; + setSelectedTools: (ids: 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, + + // Selected config + selectedModel: null, + selectedAgent: null, + selectedTools: [], + + // Available options + availableModels: [], + availableTools: [], + availableAgents: [], + + // Actions + send: async (content: string, references?: ChatReference[]) => { + const { selectedModel, selectedAgent, selectedTools, conversationId } = get(); + + if (!selectedModel) { + set({ error: 'No model selected' }); + return; + } + + // 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, + }; + + 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); + } + }, +})); + +/** + * Handle incoming chat events - all events update the messages array + */ +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': { + // Accumulate streaming text + useChatStore.setState((state) => ({ + streamText: state.streamText + event.text, + })); + break; + } + + case 'thinking': { + // Accumulate thinking text + useChatStore.setState((state) => ({ + streamThinking: state.streamThinking + event.text, + })); + break; + } + + 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': { + // 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': { + 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': { + useChatStore.setState({ + error: `${event.code}: ${event.message}`, + isStreaming: false, + requestId: null, + }); + 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..3731565 --- /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..e68ea21 --- /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..43c6dc0 --- /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..a750545 --- /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';