From e57549c06e467b412a3f2ec0575f58ac78db7752 Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:20:08 +0100 Subject: [PATCH 01/76] feat(ui): add React Query foundation and provider setup - Install @tanstack/react-query and @tanstack/react-query-devtools - Add QueryClient with default stale times and retry config - Create query-keys.ts factory for consistent cache key management - Wrap app root with QueryClientProvider and DevTools Co-Authored-By: Claude Opus 4.5 --- apps/ui/package.json | 3 +- apps/ui/src/lib/query-client.ts | 138 ++++++++++++++++ apps/ui/src/lib/query-keys.ts | 280 ++++++++++++++++++++++++++++++++ apps/ui/src/routes/__root.tsx | 12 +- package-lock.json | 51 ++++-- 5 files changed, 469 insertions(+), 15 deletions(-) create mode 100644 apps/ui/src/lib/query-client.ts create mode 100644 apps/ui/src/lib/query-keys.ts diff --git a/apps/ui/package.json b/apps/ui/package.json index b28ad8c7..200df952 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -63,7 +63,8 @@ "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "1.2.8", - "@tanstack/react-query": "5.90.12", + "@tanstack/react-query": "^5.90.17", + "@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-router": "1.141.6", "@uiw/react-codemirror": "4.25.4", "@xterm/addon-fit": "0.10.0", diff --git a/apps/ui/src/lib/query-client.ts b/apps/ui/src/lib/query-client.ts new file mode 100644 index 00000000..82344f2a --- /dev/null +++ b/apps/ui/src/lib/query-client.ts @@ -0,0 +1,138 @@ +/** + * React Query Client Configuration + * + * Central configuration for TanStack React Query. + * Provides default options for queries and mutations including + * caching, retries, and error handling. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { createLogger } from '@automaker/utils/logger'; +import { isConnectionError, handleServerOffline } from './http-api-client'; + +const logger = createLogger('QueryClient'); + +/** + * Default stale times for different data types + */ +export const STALE_TIMES = { + /** Features change frequently during auto-mode */ + FEATURES: 60 * 1000, // 1 minute + /** GitHub data is relatively stable */ + GITHUB: 2 * 60 * 1000, // 2 minutes + /** Running agents state changes very frequently */ + RUNNING_AGENTS: 5 * 1000, // 5 seconds + /** Agent output changes during streaming */ + AGENT_OUTPUT: 5 * 1000, // 5 seconds + /** Usage data with polling */ + USAGE: 30 * 1000, // 30 seconds + /** Models rarely change */ + MODELS: 5 * 60 * 1000, // 5 minutes + /** CLI status rarely changes */ + CLI_STATUS: 5 * 60 * 1000, // 5 minutes + /** Settings are relatively stable */ + SETTINGS: 2 * 60 * 1000, // 2 minutes + /** Worktrees change during feature development */ + WORKTREES: 30 * 1000, // 30 seconds + /** Sessions rarely change */ + SESSIONS: 2 * 60 * 1000, // 2 minutes + /** Default for unspecified queries */ + DEFAULT: 30 * 1000, // 30 seconds +} as const; + +/** + * Default garbage collection times (gcTime, formerly cacheTime) + */ +export const GC_TIMES = { + /** Default garbage collection time */ + DEFAULT: 5 * 60 * 1000, // 5 minutes + /** Extended for expensive queries */ + EXTENDED: 10 * 60 * 1000, // 10 minutes +} as const; + +/** + * Global error handler for queries + */ +const handleQueryError = (error: Error) => { + logger.error('Query error:', error); + + // Check for connection errors (server offline) + if (isConnectionError(error)) { + handleServerOffline(); + return; + } + + // Don't toast for auth errors - those are handled by http-api-client + if (error.message === 'Unauthorized') { + return; + } +}; + +/** + * Global error handler for mutations + */ +const handleMutationError = (error: Error) => { + logger.error('Mutation error:', error); + + // Check for connection errors + if (isConnectionError(error)) { + handleServerOffline(); + return; + } + + // Don't toast for auth errors + if (error.message === 'Unauthorized') { + return; + } + + // Show error toast for other errors + toast.error('Operation failed', { + description: error.message || 'An unexpected error occurred', + }); +}; + +/** + * Create and configure the QueryClient singleton + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE_TIMES.DEFAULT, + gcTime: GC_TIMES.DEFAULT, + retry: (failureCount, error) => { + // Don't retry on auth errors + if (error instanceof Error && error.message === 'Unauthorized') { + return false; + } + // Don't retry on connection errors (server offline) + if (isConnectionError(error)) { + return false; + } + // Retry up to 2 times for other errors + return failureCount < 2; + }, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + // Don't refetch on mount if data is fresh + refetchOnMount: true, + }, + mutations: { + onError: handleMutationError, + retry: false, // Don't auto-retry mutations + }, + }, +}); + +/** + * Set up global query error handling + * This catches errors that aren't handled by individual queries + */ +queryClient.getQueryCache().subscribe((event) => { + if (event.type === 'updated' && event.query.state.status === 'error') { + const error = event.query.state.error; + if (error instanceof Error) { + handleQueryError(error); + } + } +}); diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts new file mode 100644 index 00000000..b0eb7291 --- /dev/null +++ b/apps/ui/src/lib/query-keys.ts @@ -0,0 +1,280 @@ +/** + * Query Keys Factory + * + * Centralized query key definitions for React Query. + * Following the factory pattern for type-safe, consistent query keys. + * + * @see https://tkdodo.eu/blog/effective-react-query-keys + */ + +/** + * Query keys for all API endpoints + * + * Structure follows the pattern: + * - ['entity'] for listing/global + * - ['entity', id] for single item + * - ['entity', id, 'sub-resource'] for nested resources + */ +export const queryKeys = { + // ============================================ + // Features + // ============================================ + features: { + /** All features for a project */ + all: (projectPath: string) => ['features', projectPath] as const, + /** Single feature */ + single: (projectPath: string, featureId: string) => + ['features', projectPath, featureId] as const, + /** Agent output for a feature */ + agentOutput: (projectPath: string, featureId: string) => + ['features', projectPath, featureId, 'output'] as const, + }, + + // ============================================ + // Worktrees + // ============================================ + worktrees: { + /** All worktrees for a project */ + all: (projectPath: string) => ['worktrees', projectPath] as const, + /** Single worktree info */ + single: (projectPath: string, featureId: string) => + ['worktrees', projectPath, featureId] as const, + /** Branches for a worktree */ + branches: (worktreePath: string) => ['worktrees', 'branches', worktreePath] as const, + /** Worktree status */ + status: (projectPath: string, featureId: string) => + ['worktrees', projectPath, featureId, 'status'] as const, + /** Worktree diffs */ + diffs: (projectPath: string, featureId: string) => + ['worktrees', projectPath, featureId, 'diffs'] as const, + /** Init script for a project */ + initScript: (projectPath: string) => ['worktrees', projectPath, 'init-script'] as const, + /** Available editors */ + editors: () => ['worktrees', 'editors'] as const, + }, + + // ============================================ + // GitHub + // ============================================ + github: { + /** GitHub issues for a project */ + issues: (projectPath: string) => ['github', 'issues', projectPath] as const, + /** GitHub PRs for a project */ + prs: (projectPath: string) => ['github', 'prs', projectPath] as const, + /** GitHub validations for a project */ + validations: (projectPath: string) => ['github', 'validations', projectPath] as const, + /** Single validation */ + validation: (projectPath: string, issueNumber: number) => + ['github', 'validations', projectPath, issueNumber] as const, + /** Issue comments */ + issueComments: (projectPath: string, issueNumber: number) => + ['github', 'issues', projectPath, issueNumber, 'comments'] as const, + /** Remote info */ + remote: (projectPath: string) => ['github', 'remote', projectPath] as const, + }, + + // ============================================ + // Settings + // ============================================ + settings: { + /** Global settings */ + global: () => ['settings', 'global'] as const, + /** Project-specific settings */ + project: (projectPath: string) => ['settings', 'project', projectPath] as const, + /** Settings status */ + status: () => ['settings', 'status'] as const, + /** Credentials (API keys) */ + credentials: () => ['settings', 'credentials'] as const, + /** Discovered agents */ + agents: (projectPath: string) => ['settings', 'agents', projectPath] as const, + }, + + // ============================================ + // Usage & Billing + // ============================================ + usage: { + /** Claude API usage */ + claude: () => ['usage', 'claude'] as const, + /** Codex API usage */ + codex: () => ['usage', 'codex'] as const, + }, + + // ============================================ + // Models + // ============================================ + models: { + /** Available models */ + available: () => ['models', 'available'] as const, + /** Codex models */ + codex: () => ['models', 'codex'] as const, + /** OpenCode models */ + opencode: () => ['models', 'opencode'] as const, + /** OpenCode providers */ + opencodeProviders: () => ['models', 'opencode', 'providers'] as const, + /** Provider status */ + providers: () => ['models', 'providers'] as const, + }, + + // ============================================ + // Sessions + // ============================================ + sessions: { + /** All sessions */ + all: (includeArchived?: boolean) => ['sessions', { includeArchived }] as const, + /** Session history */ + history: (sessionId: string) => ['sessions', sessionId, 'history'] as const, + /** Session queue */ + queue: (sessionId: string) => ['sessions', sessionId, 'queue'] as const, + }, + + // ============================================ + // Running Agents + // ============================================ + runningAgents: { + /** All running agents */ + all: () => ['runningAgents'] as const, + }, + + // ============================================ + // Auto Mode + // ============================================ + autoMode: { + /** Auto mode status */ + status: (projectPath?: string) => ['autoMode', 'status', projectPath] as const, + /** Context exists check */ + contextExists: (projectPath: string, featureId: string) => + ['autoMode', projectPath, featureId, 'context'] as const, + }, + + // ============================================ + // Ideation + // ============================================ + ideation: { + /** Ideation prompts */ + prompts: () => ['ideation', 'prompts'] as const, + /** Ideas for a project */ + ideas: (projectPath: string) => ['ideation', 'ideas', projectPath] as const, + /** Single idea */ + idea: (projectPath: string, ideaId: string) => + ['ideation', 'ideas', projectPath, ideaId] as const, + /** Session */ + session: (projectPath: string, sessionId: string) => + ['ideation', 'session', projectPath, sessionId] as const, + }, + + // ============================================ + // CLI Status + // ============================================ + cli: { + /** Claude CLI status */ + claude: () => ['cli', 'claude'] as const, + /** Cursor CLI status */ + cursor: () => ['cli', 'cursor'] as const, + /** Codex CLI status */ + codex: () => ['cli', 'codex'] as const, + /** OpenCode CLI status */ + opencode: () => ['cli', 'opencode'] as const, + /** GitHub CLI status */ + github: () => ['cli', 'github'] as const, + /** API keys status */ + apiKeys: () => ['cli', 'apiKeys'] as const, + /** Platform info */ + platform: () => ['cli', 'platform'] as const, + }, + + // ============================================ + // Cursor Permissions + // ============================================ + cursorPermissions: { + /** Cursor permissions for a project */ + permissions: (projectPath?: string) => ['cursorPermissions', projectPath] as const, + }, + + // ============================================ + // Workspace + // ============================================ + workspace: { + /** Workspace config */ + config: () => ['workspace', 'config'] as const, + /** Workspace directories */ + directories: () => ['workspace', 'directories'] as const, + }, + + // ============================================ + // MCP (Model Context Protocol) + // ============================================ + mcp: { + /** MCP server tools */ + tools: (serverId: string) => ['mcp', 'tools', serverId] as const, + }, + + // ============================================ + // Pipeline + // ============================================ + pipeline: { + /** Pipeline config for a project */ + config: (projectPath: string) => ['pipeline', projectPath] as const, + }, + + // ============================================ + // Suggestions + // ============================================ + suggestions: { + /** Suggestions status */ + status: () => ['suggestions', 'status'] as const, + }, + + // ============================================ + // Spec Regeneration + // ============================================ + specRegeneration: { + /** Spec regeneration status */ + status: (projectPath?: string) => ['specRegeneration', 'status', projectPath] as const, + }, + + // ============================================ + // Spec + // ============================================ + spec: { + /** Spec file content */ + file: (projectPath: string) => ['spec', 'file', projectPath] as const, + }, + + // ============================================ + // Context + // ============================================ + context: { + /** File description */ + file: (filePath: string) => ['context', 'file', filePath] as const, + /** Image description */ + image: (imagePath: string) => ['context', 'image', imagePath] as const, + }, + + // ============================================ + // File System + // ============================================ + fs: { + /** Directory listing */ + readdir: (dirPath: string) => ['fs', 'readdir', dirPath] as const, + /** File existence */ + exists: (filePath: string) => ['fs', 'exists', filePath] as const, + /** File stats */ + stat: (filePath: string) => ['fs', 'stat', filePath] as const, + }, + + // ============================================ + // Git + // ============================================ + git: { + /** Git diffs for a project */ + diffs: (projectPath: string) => ['git', 'diffs', projectPath] as const, + /** File diff */ + fileDiff: (projectPath: string, filePath: string) => + ['git', 'diffs', projectPath, filePath] as const, + }, +} as const; + +/** + * Type helper to extract query key types + */ +export type QueryKeys = typeof queryKeys; diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 9472545b..a139dbe9 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -1,5 +1,7 @@ import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'; import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { createLogger } from '@automaker/utils/logger'; import { Sidebar } from '@/components/layout/sidebar'; import { ProjectSwitcher } from '@/components/layout/project-switcher'; @@ -27,6 +29,7 @@ import { signalMigrationComplete, performSettingsMigration, } from '@/hooks/use-settings-migration'; +import { queryClient } from '@/lib/query-client'; import { Toaster } from 'sonner'; import { Menu } from 'lucide-react'; import { ThemeOption, themeOptions } from '@/config/theme-options'; @@ -856,9 +859,12 @@ function RootLayoutContent() { function RootLayout() { return ( - - - + + + + + + ); } diff --git a/package-lock.json b/package-lock.json index 00e0d253..41e0bfaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ }, "apps/server": { "name": "@automaker/server", - "version": "0.10.0", + "version": "0.11.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.1.76", @@ -80,7 +80,7 @@ }, "apps/ui": { "name": "@automaker/ui", - "version": "0.10.0", + "version": "0.11.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -108,7 +108,8 @@ "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "1.2.8", - "@tanstack/react-query": "5.90.12", + "@tanstack/react-query": "^5.90.17", + "@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-router": "1.141.6", "@uiw/react-codemirror": "4.25.4", "@xterm/addon-fit": "0.10.0", @@ -1483,7 +1484,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -5788,9 +5789,19 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", - "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "version": "5.90.17", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.17.tgz", + "integrity": "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.92.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", + "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", "license": "MIT", "funding": { "type": "github", @@ -5798,12 +5809,13 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", - "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "version": "5.90.17", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.17.tgz", + "integrity": "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ==", "license": "MIT", + "peer": true, "dependencies": { - "@tanstack/query-core": "5.90.12" + "@tanstack/query-core": "5.90.17" }, "funding": { "type": "github", @@ -5813,6 +5825,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", + "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.92.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.14", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-router": { "version": "1.141.6", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", From 2bc931a8b040a4604d1219ee56d56eb72ad7e70f Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:20:24 +0100 Subject: [PATCH 02/76] feat(ui): add React Query hooks for data fetching - Add useFeatures, useFeature, useAgentOutput for feature data - Add useGitHubIssues, useGitHubPRs, useGitHubValidations, useGitHubIssueComments - Add useClaudeUsage, useCodexUsage with polling intervals - Add useRunningAgents, useRunningAgentsCount - Add useWorktrees, useWorktreeInfo, useWorktreeStatus, useWorktreeDiffs - Add useGlobalSettings, useProjectSettings, useCredentials - Add useAvailableModels, useCodexModels, useOpencodeModels - Add useSessions, useSessionHistory, useSessionQueue - Add useIdeationPrompts, useIdeas - Add CLI status queries (claude, cursor, codex, opencode, github) - Add useCursorPermissionsQuery, useWorkspaceDirectories - Add usePipelineConfig, useSpecFile, useSpecRegenerationStatus Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/hooks/queries/index.ts | 91 +++++++ apps/ui/src/hooks/queries/use-cli-status.ts | 147 ++++++++++ .../hooks/queries/use-cursor-permissions.ts | 58 ++++ apps/ui/src/hooks/queries/use-features.ts | 127 +++++++++ apps/ui/src/hooks/queries/use-git.ts | 37 +++ apps/ui/src/hooks/queries/use-github.ts | 184 +++++++++++++ apps/ui/src/hooks/queries/use-ideation.ts | 86 ++++++ apps/ui/src/hooks/queries/use-models.ts | 134 ++++++++++ apps/ui/src/hooks/queries/use-pipeline.ts | 39 +++ .../src/hooks/queries/use-running-agents.ts | 61 +++++ apps/ui/src/hooks/queries/use-sessions.ts | 86 ++++++ apps/ui/src/hooks/queries/use-settings.ts | 122 +++++++++ apps/ui/src/hooks/queries/use-spec.ts | 103 +++++++ apps/ui/src/hooks/queries/use-usage.ts | 77 ++++++ apps/ui/src/hooks/queries/use-workspace.ts | 42 +++ apps/ui/src/hooks/queries/use-worktrees.ts | 252 ++++++++++++++++++ 16 files changed, 1646 insertions(+) create mode 100644 apps/ui/src/hooks/queries/index.ts create mode 100644 apps/ui/src/hooks/queries/use-cli-status.ts create mode 100644 apps/ui/src/hooks/queries/use-cursor-permissions.ts create mode 100644 apps/ui/src/hooks/queries/use-features.ts create mode 100644 apps/ui/src/hooks/queries/use-git.ts create mode 100644 apps/ui/src/hooks/queries/use-github.ts create mode 100644 apps/ui/src/hooks/queries/use-ideation.ts create mode 100644 apps/ui/src/hooks/queries/use-models.ts create mode 100644 apps/ui/src/hooks/queries/use-pipeline.ts create mode 100644 apps/ui/src/hooks/queries/use-running-agents.ts create mode 100644 apps/ui/src/hooks/queries/use-sessions.ts create mode 100644 apps/ui/src/hooks/queries/use-settings.ts create mode 100644 apps/ui/src/hooks/queries/use-spec.ts create mode 100644 apps/ui/src/hooks/queries/use-usage.ts create mode 100644 apps/ui/src/hooks/queries/use-workspace.ts create mode 100644 apps/ui/src/hooks/queries/use-worktrees.ts diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts new file mode 100644 index 00000000..18e38120 --- /dev/null +++ b/apps/ui/src/hooks/queries/index.ts @@ -0,0 +1,91 @@ +/** + * Query Hooks Barrel Export + * + * Central export point for all React Query hooks. + * Import from this file for cleaner imports across the app. + * + * @example + * ```tsx + * import { useFeatures, useGitHubIssues, useClaudeUsage } from '@/hooks/queries'; + * ``` + */ + +// Features +export { useFeatures, useFeature, useAgentOutput } from './use-features'; + +// GitHub +export { + useGitHubIssues, + useGitHubPRs, + useGitHubValidations, + useGitHubRemote, + useGitHubIssueComments, +} from './use-github'; + +// Usage +export { useClaudeUsage, useCodexUsage } from './use-usage'; + +// Running Agents +export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; + +// Worktrees +export { + useWorktrees, + useWorktreeInfo, + useWorktreeStatus, + useWorktreeDiffs, + useWorktreeBranches, + useWorktreeInitScript, + useAvailableEditors, +} from './use-worktrees'; + +// Settings +export { + useGlobalSettings, + useProjectSettings, + useSettingsStatus, + useCredentials, + useDiscoveredAgents, +} from './use-settings'; + +// Models +export { + useAvailableModels, + useCodexModels, + useOpencodeModels, + useOpencodeProviders, + useModelProviders, +} from './use-models'; + +// CLI Status +export { + useClaudeCliStatus, + useCursorCliStatus, + useCodexCliStatus, + useOpencodeCliStatus, + useGitHubCliStatus, + useApiKeysStatus, + usePlatformInfo, +} from './use-cli-status'; + +// Ideation +export { useIdeationPrompts, useIdeas, useIdea } from './use-ideation'; + +// Sessions +export { useSessions, useSessionHistory, useSessionQueue } from './use-sessions'; + +// Git +export { useGitDiffs } from './use-git'; + +// Pipeline +export { usePipelineConfig } from './use-pipeline'; + +// Spec +export { useSpecFile, useSpecRegenerationStatus } from './use-spec'; + +// Cursor Permissions +export { useCursorPermissionsQuery } from './use-cursor-permissions'; +export type { CursorPermissionsData } from './use-cursor-permissions'; + +// Workspace +export { useWorkspaceDirectories } from './use-workspace'; diff --git a/apps/ui/src/hooks/queries/use-cli-status.ts b/apps/ui/src/hooks/queries/use-cli-status.ts new file mode 100644 index 00000000..71ea2ae9 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-cli-status.ts @@ -0,0 +1,147 @@ +/** + * CLI Status Query Hooks + * + * React Query hooks for fetching CLI tool status (Claude, Cursor, Codex, etc.) + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +/** + * Fetch Claude CLI status + * + * @returns Query result with Claude CLI status + */ +export function useClaudeCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.claude(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getClaudeStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Claude status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch Cursor CLI status + * + * @returns Query result with Cursor CLI status + */ +export function useCursorCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.cursor(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getCursorStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Cursor status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch Codex CLI status + * + * @returns Query result with Codex CLI status + */ +export function useCodexCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.codex(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getCodexStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Codex status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch OpenCode CLI status + * + * @returns Query result with OpenCode CLI status + */ +export function useOpencodeCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.opencode(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getOpencodeStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch OpenCode status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch GitHub CLI status + * + * @returns Query result with GitHub CLI status + */ +export function useGitHubCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.github(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getGhStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch GitHub CLI status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch API keys status + * + * @returns Query result with API keys status + */ +export function useApiKeysStatus() { + return useQuery({ + queryKey: queryKeys.cli.apiKeys(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getApiKeys(); + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch platform info + * + * @returns Query result with platform info + */ +export function usePlatformInfo() { + return useQuery({ + queryKey: queryKeys.cli.platform(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getPlatform(); + if (!result.success) { + throw new Error('Failed to fetch platform info'); + } + return result; + }, + staleTime: Infinity, // Platform info never changes + }); +} diff --git a/apps/ui/src/hooks/queries/use-cursor-permissions.ts b/apps/ui/src/hooks/queries/use-cursor-permissions.ts new file mode 100644 index 00000000..5d2e24f0 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-cursor-permissions.ts @@ -0,0 +1,58 @@ +/** + * Cursor Permissions Query Hooks + * + * React Query hooks for fetching Cursor CLI permissions. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { CursorPermissionProfile } from '@automaker/types'; + +export interface CursorPermissionsData { + activeProfile: CursorPermissionProfile | null; + effectivePermissions: { allow: string[]; deny: string[] } | null; + hasProjectConfig: boolean; + availableProfiles: Array<{ + id: string; + name: string; + description: string; + permissions: { allow: string[]; deny: string[] }; + }>; +} + +/** + * Fetch Cursor permissions for a project + * + * @param projectPath - Optional path to the project + * @param enabled - Whether to enable the query + * @returns Query result with permissions data + * + * @example + * ```tsx + * const { data: permissions, isLoading, refetch } = useCursorPermissions(projectPath); + * ``` + */ +export function useCursorPermissionsQuery(projectPath?: string, enabled = true) { + return useQuery({ + queryKey: queryKeys.cursorPermissions.permissions(projectPath), + queryFn: async (): Promise => { + const api = getHttpApiClient(); + const result = await api.setup.getCursorPermissions(projectPath); + + if (!result.success) { + throw new Error(result.error || 'Failed to load permissions'); + } + + return { + activeProfile: result.activeProfile || null, + effectivePermissions: result.effectivePermissions || null, + hasProjectConfig: result.hasProjectConfig || false, + availableProfiles: result.availableProfiles || [], + }; + }, + enabled, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-features.ts b/apps/ui/src/hooks/queries/use-features.ts new file mode 100644 index 00000000..89a67987 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-features.ts @@ -0,0 +1,127 @@ +/** + * Features Query Hooks + * + * React Query hooks for fetching and managing features data. + * These hooks replace manual useState/useEffect patterns with + * automatic caching, deduplication, and background refetching. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { Feature } from '@/store/app-store'; + +/** + * Fetch all features for a project + * + * @param projectPath - Path to the project + * @returns Query result with features array + * + * @example + * ```tsx + * const { data: features, isLoading, error } = useFeatures(currentProject?.path); + * ``` + */ +export function useFeatures(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.features.all(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.features?.getAll(projectPath); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch features'); + } + return (result.features ?? []) as Feature[]; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.FEATURES, + }); +} + +interface UseFeatureOptions { + enabled?: boolean; + /** Override polling interval (ms). Use false to disable polling. */ + pollingInterval?: number | false; +} + +/** + * Fetch a single feature by ID + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature to fetch + * @param options - Query options including enabled and polling interval + * @returns Query result with single feature + */ +export function useFeature( + projectPath: string | undefined, + featureId: string | undefined, + options: UseFeatureOptions = {} +) { + const { enabled = true, pollingInterval } = options; + + return useQuery({ + queryKey: queryKeys.features.single(projectPath ?? '', featureId ?? ''), + queryFn: async (): Promise => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.features?.get(projectPath, featureId); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch feature'); + } + return (result.feature as Feature) ?? null; + }, + enabled: !!projectPath && !!featureId && enabled, + staleTime: STALE_TIMES.FEATURES, + refetchInterval: pollingInterval, + }); +} + +interface UseAgentOutputOptions { + enabled?: boolean; + /** Override polling interval (ms). Use false to disable polling. */ + pollingInterval?: number | false; +} + +/** + * Fetch agent output for a feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @param options - Query options including enabled and polling interval + * @returns Query result with agent output string + */ +export function useAgentOutput( + projectPath: string | undefined, + featureId: string | undefined, + options: UseAgentOutputOptions = {} +) { + const { enabled = true, pollingInterval } = options; + + return useQuery({ + queryKey: queryKeys.features.agentOutput(projectPath ?? '', featureId ?? ''), + queryFn: async (): Promise => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.features?.getAgentOutput(projectPath, featureId); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch agent output'); + } + return result.content ?? ''; + }, + enabled: !!projectPath && !!featureId && enabled, + staleTime: STALE_TIMES.AGENT_OUTPUT, + // Use provided polling interval or default behavior + refetchInterval: + pollingInterval !== undefined + ? pollingInterval + : (query) => { + // Only poll if we have data and it's not empty (indicating active task) + if (query.state.data && query.state.data.length > 0) { + return 5000; // 5 seconds + } + return false; + }, + }); +} diff --git a/apps/ui/src/hooks/queries/use-git.ts b/apps/ui/src/hooks/queries/use-git.ts new file mode 100644 index 00000000..ef4be5ca --- /dev/null +++ b/apps/ui/src/hooks/queries/use-git.ts @@ -0,0 +1,37 @@ +/** + * Git Query Hooks + * + * React Query hooks for git operations. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +/** + * Fetch git diffs for a project (main project, not worktree) + * + * @param projectPath - Path to the project + * @param enabled - Whether to enable the query + * @returns Query result with files and diff content + */ +export function useGitDiffs(projectPath: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.git.diffs(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.git.getDiffs(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch diffs'); + } + return { + files: result.files ?? [], + diff: result.diff ?? '', + }; + }, + enabled: !!projectPath && enabled, + staleTime: STALE_TIMES.WORKTREES, + }); +} diff --git a/apps/ui/src/hooks/queries/use-github.ts b/apps/ui/src/hooks/queries/use-github.ts new file mode 100644 index 00000000..47c3de7c --- /dev/null +++ b/apps/ui/src/hooks/queries/use-github.ts @@ -0,0 +1,184 @@ +/** + * GitHub Query Hooks + * + * React Query hooks for fetching GitHub issues, PRs, and validations. + */ + +import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { GitHubIssue, GitHubPR, GitHubComment, IssueValidation } from '@/lib/electron'; + +interface GitHubIssuesResult { + openIssues: GitHubIssue[]; + closedIssues: GitHubIssue[]; +} + +interface GitHubPRsResult { + openPRs: GitHubPR[]; + mergedPRs: GitHubPR[]; +} + +/** + * Fetch GitHub issues for a project + * + * @param projectPath - Path to the project + * @returns Query result with open and closed issues + * + * @example + * ```tsx + * const { data, isLoading } = useGitHubIssues(currentProject?.path); + * const { openIssues, closedIssues } = data ?? { openIssues: [], closedIssues: [] }; + * ``` + */ +export function useGitHubIssues(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.github.issues(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.listIssues(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch issues'); + } + return { + openIssues: result.openIssues ?? [], + closedIssues: result.closedIssues ?? [], + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Fetch GitHub PRs for a project + * + * @param projectPath - Path to the project + * @returns Query result with open and merged PRs + */ +export function useGitHubPRs(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.github.prs(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.listPRs(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch PRs'); + } + return { + openPRs: result.openPRs ?? [], + mergedPRs: result.mergedPRs ?? [], + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Fetch GitHub validations for a project + * + * @param projectPath - Path to the project + * @param issueNumber - Optional issue number to filter by + * @returns Query result with validations + */ +export function useGitHubValidations(projectPath: string | undefined, issueNumber?: number) { + return useQuery({ + queryKey: issueNumber + ? queryKeys.github.validation(projectPath ?? '', issueNumber) + : queryKeys.github.validations(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.getValidations(projectPath, issueNumber); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch validations'); + } + return result.validations ?? []; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Check GitHub remote for a project + * + * @param projectPath - Path to the project + * @returns Query result with remote info + */ +export function useGitHubRemote(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.github.remote(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.checkRemote(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to check remote'); + } + return { + hasRemote: result.hasRemote ?? false, + owner: result.owner, + repo: result.repo, + url: result.url, + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Fetch comments for a GitHub issue with pagination support + * + * Uses useInfiniteQuery for proper "load more" pagination. + * + * @param projectPath - Path to the project + * @param issueNumber - Issue number + * @returns Infinite query result with comments and pagination helpers + * + * @example + * ```tsx + * const { + * data, + * isLoading, + * isFetchingNextPage, + * hasNextPage, + * fetchNextPage, + * refetch, + * } = useGitHubIssueComments(projectPath, issueNumber); + * + * // Get all comments flattened + * const comments = data?.pages.flatMap(page => page.comments) ?? []; + * ``` + */ +export function useGitHubIssueComments( + projectPath: string | undefined, + issueNumber: number | undefined +) { + return useInfiniteQuery({ + queryKey: queryKeys.github.issueComments(projectPath ?? '', issueNumber ?? 0), + queryFn: async ({ pageParam }: { pageParam: string | undefined }) => { + if (!projectPath || !issueNumber) throw new Error('Missing project path or issue number'); + const api = getElectronAPI(); + const result = await api.github.getIssueComments(projectPath, issueNumber, pageParam); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch comments'); + } + return { + comments: (result.comments ?? []) as GitHubComment[], + totalCount: result.totalCount ?? 0, + hasNextPage: result.hasNextPage ?? false, + endCursor: result.endCursor as string | undefined, + }; + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.endCursor : undefined), + enabled: !!projectPath && !!issueNumber, + staleTime: STALE_TIMES.GITHUB, + }); +} diff --git a/apps/ui/src/hooks/queries/use-ideation.ts b/apps/ui/src/hooks/queries/use-ideation.ts new file mode 100644 index 00000000..aa2bd023 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-ideation.ts @@ -0,0 +1,86 @@ +/** + * Ideation Query Hooks + * + * React Query hooks for fetching ideation prompts and ideas. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +/** + * Fetch ideation prompts + * + * @returns Query result with prompts and categories + * + * @example + * ```tsx + * const { data, isLoading, error } = useIdeationPrompts(); + * const { prompts, categories } = data ?? { prompts: [], categories: [] }; + * ``` + */ +export function useIdeationPrompts() { + return useQuery({ + queryKey: queryKeys.ideation.prompts(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.ideation?.getPrompts(); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch prompts'); + } + return { + prompts: result.prompts ?? [], + categories: result.categories ?? [], + }; + }, + staleTime: STALE_TIMES.SETTINGS, // Prompts rarely change + }); +} + +/** + * Fetch ideas for a project + * + * @param projectPath - Path to the project + * @returns Query result with ideas array + */ +export function useIdeas(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.ideation.ideas(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.ideation?.listIdeas(projectPath); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch ideas'); + } + return result.ideas ?? []; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.FEATURES, + }); +} + +/** + * Fetch a single idea by ID + * + * @param projectPath - Path to the project + * @param ideaId - ID of the idea + * @returns Query result with single idea + */ +export function useIdea(projectPath: string | undefined, ideaId: string | undefined) { + return useQuery({ + queryKey: queryKeys.ideation.idea(projectPath ?? '', ideaId ?? ''), + queryFn: async () => { + if (!projectPath || !ideaId) throw new Error('Missing project path or idea ID'); + const api = getElectronAPI(); + const result = await api.ideation?.getIdea(projectPath, ideaId); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch idea'); + } + return result.idea; + }, + enabled: !!projectPath && !!ideaId, + staleTime: STALE_TIMES.FEATURES, + }); +} diff --git a/apps/ui/src/hooks/queries/use-models.ts b/apps/ui/src/hooks/queries/use-models.ts new file mode 100644 index 00000000..d917492b --- /dev/null +++ b/apps/ui/src/hooks/queries/use-models.ts @@ -0,0 +1,134 @@ +/** + * Models Query Hooks + * + * React Query hooks for fetching available AI models. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface CodexModel { + id: string; + label: string; + description: string; + hasThinking: boolean; + supportsVision: boolean; + tier: 'premium' | 'standard' | 'basic'; + isDefault: boolean; +} + +interface OpencodeModel { + id: string; + name: string; + modelString: string; + provider: string; + description: string; + supportsTools: boolean; + supportsVision: boolean; + tier: string; + default?: boolean; +} + +/** + * Fetch available models + * + * @returns Query result with available models + */ +export function useAvailableModels() { + return useQuery({ + queryKey: queryKeys.models.available(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.model.getAvailable(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch available models'); + } + return result.models ?? []; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch Codex models + * + * @param refresh - Force refresh from server + * @returns Query result with Codex models + */ +export function useCodexModels(refresh = false) { + return useQuery({ + queryKey: queryKeys.models.codex(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.codex.getModels(refresh); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Codex models'); + } + return (result.models ?? []) as CodexModel[]; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch OpenCode models + * + * @param refresh - Force refresh from server + * @returns Query result with OpenCode models + */ +export function useOpencodeModels(refresh = false) { + return useQuery({ + queryKey: queryKeys.models.opencode(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.setup.getOpencodeModels(refresh); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch OpenCode models'); + } + return (result.models ?? []) as OpencodeModel[]; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch OpenCode providers + * + * @returns Query result with OpenCode providers + */ +export function useOpencodeProviders() { + return useQuery({ + queryKey: queryKeys.models.opencodeProviders(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getOpencodeProviders(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch OpenCode providers'); + } + return result.providers ?? []; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch model providers status + * + * @returns Query result with provider status + */ +export function useModelProviders() { + return useQuery({ + queryKey: queryKeys.models.providers(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.model.checkProviders(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch providers'); + } + return result.providers ?? {}; + }, + staleTime: STALE_TIMES.MODELS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-pipeline.ts b/apps/ui/src/hooks/queries/use-pipeline.ts new file mode 100644 index 00000000..916810d6 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-pipeline.ts @@ -0,0 +1,39 @@ +/** + * Pipeline Query Hooks + * + * React Query hooks for fetching pipeline configuration. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { PipelineConfig } from '@/store/app-store'; + +/** + * Fetch pipeline config for a project + * + * @param projectPath - Path to the project + * @returns Query result with pipeline config + * + * @example + * ```tsx + * const { data: pipelineConfig, isLoading } = usePipelineConfig(currentProject?.path); + * ``` + */ +export function usePipelineConfig(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.pipeline.config(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getHttpApiClient(); + const result = await api.pipeline.getConfig(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch pipeline config'); + } + return result.config ?? null; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-running-agents.ts b/apps/ui/src/hooks/queries/use-running-agents.ts new file mode 100644 index 00000000..a661d9c3 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-running-agents.ts @@ -0,0 +1,61 @@ +/** + * Running Agents Query Hook + * + * React Query hook for fetching currently running agents. + * This data is invalidated by WebSocket events when agents start/stop. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI, type RunningAgent } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface RunningAgentsResult { + agents: RunningAgent[]; + count: number; +} + +/** + * Fetch all currently running agents + * + * @returns Query result with running agents and total count + * + * @example + * ```tsx + * const { data, isLoading } = useRunningAgents(); + * const { agents, count } = data ?? { agents: [], count: 0 }; + * ``` + */ +export function useRunningAgents() { + return useQuery({ + queryKey: queryKeys.runningAgents.all(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.runningAgents.getAll(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch running agents'); + } + return { + agents: result.runningAgents ?? [], + count: result.totalCount ?? 0, + }; + }, + staleTime: STALE_TIMES.RUNNING_AGENTS, + // Note: Don't use refetchInterval here - rely on WebSocket invalidation + // for real-time updates instead of polling + }); +} + +/** + * Get running agents count + * This is a selector that derives count from the main query + * + * @returns Query result with just the count + */ +export function useRunningAgentsCount() { + const query = useRunningAgents(); + return { + ...query, + data: query.data?.count ?? 0, + }; +} diff --git a/apps/ui/src/hooks/queries/use-sessions.ts b/apps/ui/src/hooks/queries/use-sessions.ts new file mode 100644 index 00000000..001968e1 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-sessions.ts @@ -0,0 +1,86 @@ +/** + * Sessions Query Hooks + * + * React Query hooks for fetching session data. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { SessionListItem } from '@/types/electron'; + +/** + * Fetch all sessions + * + * @param includeArchived - Whether to include archived sessions + * @returns Query result with sessions array + * + * @example + * ```tsx + * const { data: sessions, isLoading } = useSessions(false); + * ``` + */ +export function useSessions(includeArchived = false) { + return useQuery({ + queryKey: queryKeys.sessions.all(includeArchived), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.sessions.list(includeArchived); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch sessions'); + } + return result.sessions ?? []; + }, + staleTime: STALE_TIMES.SESSIONS, + }); +} + +/** + * Fetch session history + * + * @param sessionId - ID of the session + * @returns Query result with session messages + */ +export function useSessionHistory(sessionId: string | undefined) { + return useQuery({ + queryKey: queryKeys.sessions.history(sessionId ?? ''), + queryFn: async () => { + if (!sessionId) throw new Error('No session ID'); + const api = getElectronAPI(); + const result = await api.agent.getHistory(sessionId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch session history'); + } + return { + messages: result.messages ?? [], + isRunning: result.isRunning ?? false, + }; + }, + enabled: !!sessionId, + staleTime: STALE_TIMES.FEATURES, // Session history changes during conversations + }); +} + +/** + * Fetch session message queue + * + * @param sessionId - ID of the session + * @returns Query result with queued messages + */ +export function useSessionQueue(sessionId: string | undefined) { + return useQuery({ + queryKey: queryKeys.sessions.queue(sessionId ?? ''), + queryFn: async () => { + if (!sessionId) throw new Error('No session ID'); + const api = getElectronAPI(); + const result = await api.agent.queueList(sessionId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch queue'); + } + return result.queue ?? []; + }, + enabled: !!sessionId, + staleTime: STALE_TIMES.RUNNING_AGENTS, // Queue changes frequently during use + }); +} diff --git a/apps/ui/src/hooks/queries/use-settings.ts b/apps/ui/src/hooks/queries/use-settings.ts new file mode 100644 index 00000000..e528bc63 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-settings.ts @@ -0,0 +1,122 @@ +/** + * Settings Query Hooks + * + * React Query hooks for fetching global and project settings. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { GlobalSettings, ProjectSettings } from '@automaker/types'; + +/** + * Fetch global settings + * + * @returns Query result with global settings + * + * @example + * ```tsx + * const { data: settings, isLoading } = useGlobalSettings(); + * ``` + */ +export function useGlobalSettings() { + return useQuery({ + queryKey: queryKeys.settings.global(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.settings.getGlobal(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch global settings'); + } + return result.settings as GlobalSettings; + }, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch project-specific settings + * + * @param projectPath - Path to the project + * @returns Query result with project settings + */ +export function useProjectSettings(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.settings.project(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.settings.getProject(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch project settings'); + } + return result.settings as ProjectSettings; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch settings status (migration status, etc.) + * + * @returns Query result with settings status + */ +export function useSettingsStatus() { + return useQuery({ + queryKey: queryKeys.settings.status(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.settings.getStatus(); + return result; + }, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch credentials status (masked API keys) + * + * @returns Query result with credentials info + */ +export function useCredentials() { + return useQuery({ + queryKey: queryKeys.settings.credentials(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.settings.getCredentials(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch credentials'); + } + return result.credentials; + }, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Discover agents for a project + * + * @param projectPath - Path to the project + * @param sources - Sources to search ('user' | 'project') + * @returns Query result with discovered agents + */ +export function useDiscoveredAgents( + projectPath: string | undefined, + sources?: Array<'user' | 'project'> +) { + return useQuery({ + queryKey: queryKeys.settings.agents(projectPath ?? ''), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.settings.discoverAgents(projectPath, sources); + if (!result.success) { + throw new Error(result.error || 'Failed to discover agents'); + } + return result.agents ?? []; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-spec.ts b/apps/ui/src/hooks/queries/use-spec.ts new file mode 100644 index 00000000..c81dea34 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-spec.ts @@ -0,0 +1,103 @@ +/** + * Spec Query Hooks + * + * React Query hooks for fetching spec file content and regeneration status. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface SpecFileResult { + content: string; + exists: boolean; +} + +interface SpecRegenerationStatusResult { + isRunning: boolean; + currentPhase?: string; +} + +/** + * Fetch spec file content for a project + * + * @param projectPath - Path to the project + * @returns Query result with spec content and existence flag + * + * @example + * ```tsx + * const { data, isLoading } = useSpecFile(currentProject?.path); + * if (data?.exists) { + * console.log(data.content); + * } + * ``` + */ +export function useSpecFile(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.spec.file(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + + const api = getElectronAPI(); + const result = await api.readFile(`${projectPath}/.automaker/app_spec.txt`); + + if (result.success && result.content) { + return { + content: result.content, + exists: true, + }; + } + + return { + content: '', + exists: false, + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Check spec regeneration status for a project + * + * @param projectPath - Path to the project + * @param enabled - Whether to enable the query (useful during regeneration) + * @returns Query result with regeneration status + * + * @example + * ```tsx + * const { data } = useSpecRegenerationStatus(projectPath, isRegenerating); + * if (data?.isRunning) { + * // Show loading indicator + * } + * ``` + */ +export function useSpecRegenerationStatus(projectPath: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.specRegeneration.status(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + + const api = getElectronAPI(); + if (!api.specRegeneration) { + return { isRunning: false }; + } + + const status = await api.specRegeneration.status(projectPath); + + if (status.success) { + return { + isRunning: status.isRunning ?? false, + currentPhase: status.currentPhase, + }; + } + + return { isRunning: false }; + }, + enabled: !!projectPath && enabled, + staleTime: 5000, // Check every 5 seconds when active + refetchInterval: enabled ? 5000 : false, + }); +} diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts new file mode 100644 index 00000000..38de9bb8 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -0,0 +1,77 @@ +/** + * Usage Query Hooks + * + * React Query hooks for fetching Claude and Codex API usage data. + * These hooks include automatic polling for real-time usage updates. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { ClaudeUsage, CodexUsage } from '@/store/app-store'; + +/** Polling interval for usage data (60 seconds) */ +const USAGE_POLLING_INTERVAL = 60 * 1000; + +/** + * Fetch Claude API usage data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Claude usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useClaudeUsage(isPopoverOpen); + * ``` + */ +export function useClaudeUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.claude(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.claude.getUsage(); + // Check if result is an error response + if ('error' in result) { + throw new Error(result.message || result.error); + } + return result; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + }); +} + +/** + * Fetch Codex API usage data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Codex usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useCodexUsage(isPopoverOpen); + * ``` + */ +export function useCodexUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.codex(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.codex.getUsage(); + // Check if result is an error response + if ('error' in result) { + throw new Error(result.message || result.error); + } + return result; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + }); +} diff --git a/apps/ui/src/hooks/queries/use-workspace.ts b/apps/ui/src/hooks/queries/use-workspace.ts new file mode 100644 index 00000000..2001e2b7 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-workspace.ts @@ -0,0 +1,42 @@ +/** + * Workspace Query Hooks + * + * React Query hooks for workspace operations. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface WorkspaceDirectory { + name: string; + path: string; +} + +/** + * Fetch workspace directories + * + * @param enabled - Whether to enable the query + * @returns Query result with directories + * + * @example + * ```tsx + * const { data: directories, isLoading, error } = useWorkspaceDirectories(open); + * ``` + */ +export function useWorkspaceDirectories(enabled = true) { + return useQuery({ + queryKey: queryKeys.workspace.directories(), + queryFn: async (): Promise => { + const api = getHttpApiClient(); + const result = await api.workspace.getDirectories(); + if (!result.success) { + throw new Error(result.error || 'Failed to load directories'); + } + return result.directories ?? []; + }, + enabled, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts new file mode 100644 index 00000000..7cafbddb --- /dev/null +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -0,0 +1,252 @@ +/** + * Worktrees Query Hooks + * + * React Query hooks for fetching worktree data. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; + featureId?: string; + linkedToBranch?: string; +} + +interface RemovedWorktree { + path: string; + branch: string; +} + +interface WorktreesResult { + worktrees: WorktreeInfo[]; + removedWorktrees: RemovedWorktree[]; +} + +/** + * Fetch all worktrees for a project + * + * @param projectPath - Path to the project + * @param includeDetails - Whether to include detailed info (default: true) + * @returns Query result with worktrees array and removed worktrees + * + * @example + * ```tsx + * const { data, isLoading, refetch } = useWorktrees(currentProject?.path); + * const worktrees = data?.worktrees ?? []; + * ``` + */ +export function useWorktrees(projectPath: string | undefined, includeDetails = true) { + return useQuery({ + queryKey: queryKeys.worktrees.all(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.worktree.listAll(projectPath, includeDetails); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch worktrees'); + } + return { + worktrees: result.worktrees ?? [], + removedWorktrees: result.removedWorktrees ?? [], + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +/** + * Fetch worktree info for a specific feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @returns Query result with worktree info + */ +export function useWorktreeInfo(projectPath: string | undefined, featureId: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.single(projectPath ?? '', featureId ?? ''), + queryFn: async () => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.worktree.getInfo(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch worktree info'); + } + return result; + }, + enabled: !!projectPath && !!featureId, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +/** + * Fetch worktree status for a specific feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @returns Query result with worktree status + */ +export function useWorktreeStatus(projectPath: string | undefined, featureId: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.status(projectPath ?? '', featureId ?? ''), + queryFn: async () => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.worktree.getStatus(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch worktree status'); + } + return result; + }, + enabled: !!projectPath && !!featureId, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +/** + * Fetch worktree diffs for a specific feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @returns Query result with files and diff content + */ +export function useWorktreeDiffs(projectPath: string | undefined, featureId: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.diffs(projectPath ?? '', featureId ?? ''), + queryFn: async () => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.worktree.getDiffs(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch diffs'); + } + return { + files: result.files ?? [], + diff: result.diff ?? '', + }; + }, + enabled: !!projectPath && !!featureId, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +interface BranchInfo { + name: string; + isCurrent: boolean; + isRemote?: boolean; + lastCommit?: string; + upstream?: string; +} + +interface BranchesResult { + branches: BranchInfo[]; + aheadCount: number; + behindCount: number; + isGitRepo: boolean; + hasCommits: boolean; +} + +/** + * Fetch available branches for a worktree + * + * @param worktreePath - Path to the worktree + * @param includeRemote - Whether to include remote branches + * @returns Query result with branches, ahead/behind counts, and git repo status + */ +export function useWorktreeBranches(worktreePath: string | undefined, includeRemote = false) { + return useQuery({ + queryKey: queryKeys.worktrees.branches(worktreePath ?? ''), + queryFn: async (): Promise => { + if (!worktreePath) throw new Error('No worktree path'); + const api = getElectronAPI(); + const result = await api.worktree.listBranches(worktreePath, includeRemote); + + // Handle special git status codes + if (result.code === 'NOT_GIT_REPO') { + return { + branches: [], + aheadCount: 0, + behindCount: 0, + isGitRepo: false, + hasCommits: false, + }; + } + if (result.code === 'NO_COMMITS') { + return { + branches: [], + aheadCount: 0, + behindCount: 0, + isGitRepo: true, + hasCommits: false, + }; + } + + if (!result.success) { + throw new Error(result.error || 'Failed to fetch branches'); + } + + return { + branches: result.result?.branches ?? [], + aheadCount: result.result?.aheadCount ?? 0, + behindCount: result.result?.behindCount ?? 0, + isGitRepo: true, + hasCommits: true, + }; + }, + enabled: !!worktreePath, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +/** + * Fetch init script for a project + * + * @param projectPath - Path to the project + * @returns Query result with init script content + */ +export function useWorktreeInitScript(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.initScript(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.worktree.getInitScript(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch init script'); + } + return { + exists: result.exists ?? false, + content: result.content ?? '', + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch available editors + * + * @returns Query result with available editors + */ +export function useAvailableEditors() { + return useQuery({ + queryKey: queryKeys.worktrees.editors(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.worktree.getAvailableEditors(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch editors'); + } + return result.editors ?? []; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} From 845674128e148531cef1f63d79d7e0783d8db3ba Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:20:38 +0100 Subject: [PATCH 03/76] feat(ui): add React Query mutation hooks - Add feature mutations (create, update, delete with optimistic updates) - Add auto-mode mutations (start, stop, approve plan) - Add worktree mutations (create, delete, checkout, switch branch) - Add settings mutations (update global/project, validate API keys) - Add GitHub mutations (create PR, validate PR) - Add cursor permissions mutations (apply profile, copy config) - Add spec mutations (generate, update, save) - Add pipeline mutations (toggle, update config) - Add session mutations with cache invalidation Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/hooks/mutations/index.ts | 79 +++ .../mutations/use-auto-mode-mutations.ts | 373 ++++++++++++++ .../use-cursor-permissions-mutations.ts | 96 ++++ .../hooks/mutations/use-feature-mutations.ts | 267 ++++++++++ .../hooks/mutations/use-github-mutations.ts | 159 ++++++ .../hooks/mutations/use-ideation-mutations.ts | 82 +++ .../hooks/mutations/use-settings-mutations.ts | 144 ++++++ .../src/hooks/mutations/use-spec-mutations.ts | 179 +++++++ .../hooks/mutations/use-worktree-mutations.ts | 480 ++++++++++++++++++ 9 files changed, 1859 insertions(+) create mode 100644 apps/ui/src/hooks/mutations/index.ts create mode 100644 apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts create mode 100644 apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts create mode 100644 apps/ui/src/hooks/mutations/use-feature-mutations.ts create mode 100644 apps/ui/src/hooks/mutations/use-github-mutations.ts create mode 100644 apps/ui/src/hooks/mutations/use-ideation-mutations.ts create mode 100644 apps/ui/src/hooks/mutations/use-settings-mutations.ts create mode 100644 apps/ui/src/hooks/mutations/use-spec-mutations.ts create mode 100644 apps/ui/src/hooks/mutations/use-worktree-mutations.ts diff --git a/apps/ui/src/hooks/mutations/index.ts b/apps/ui/src/hooks/mutations/index.ts new file mode 100644 index 00000000..9cab4bea --- /dev/null +++ b/apps/ui/src/hooks/mutations/index.ts @@ -0,0 +1,79 @@ +/** + * Mutations Barrel Export + * + * Central export point for all React Query mutations. + * + * @example + * ```tsx + * import { useCreateFeature, useStartFeature, useCommitWorktree } from '@/hooks/mutations'; + * ``` + */ + +// Feature mutations +export { + useCreateFeature, + useUpdateFeature, + useDeleteFeature, + useGenerateTitle, + useBatchUpdateFeatures, +} from './use-feature-mutations'; + +// Auto mode mutations +export { + useStartFeature, + useResumeFeature, + useStopFeature, + useVerifyFeature, + useApprovePlan, + useFollowUpFeature, + useCommitFeature, + useAnalyzeProject, + useStartAutoMode, + useStopAutoMode, +} from './use-auto-mode-mutations'; + +// Settings mutations +export { + useUpdateGlobalSettings, + useUpdateProjectSettings, + useSaveCredentials, +} from './use-settings-mutations'; + +// Worktree mutations +export { + useCreateWorktree, + useDeleteWorktree, + useCommitWorktree, + usePushWorktree, + usePullWorktree, + useCreatePullRequest, + useMergeWorktree, + useSwitchBranch, + useCheckoutBranch, + useGenerateCommitMessage, + useOpenInEditor, + useInitGit, + useSetInitScript, + useDeleteInitScript, +} from './use-worktree-mutations'; + +// GitHub mutations +export { + useValidateIssue, + useMarkValidationViewed, + useGetValidationStatus, +} from './use-github-mutations'; + +// Ideation mutations +export { useGenerateIdeationSuggestions } from './use-ideation-mutations'; + +// Spec mutations +export { + useCreateSpec, + useRegenerateSpec, + useGenerateFeatures, + useSaveSpec, +} from './use-spec-mutations'; + +// Cursor Permissions mutations +export { useApplyCursorProfile, useCopyCursorConfig } from './use-cursor-permissions-mutations'; diff --git a/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts b/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts new file mode 100644 index 00000000..b94d23ad --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts @@ -0,0 +1,373 @@ +/** + * Auto Mode Mutations + * + * React Query mutations for auto mode operations like running features, + * stopping features, and plan approval. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { toast } from 'sonner'; + +/** + * Start running a feature in auto mode + * + * @param projectPath - Path to the project + * @returns Mutation for starting a feature + * + * @example + * ```tsx + * const startFeature = useStartFeature(projectPath); + * startFeature.mutate({ featureId: 'abc123', useWorktrees: true }); + * ``` + */ +export function useStartFeature(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + featureId, + useWorktrees, + worktreePath, + }: { + featureId: string; + useWorktrees?: boolean; + worktreePath?: string; + }) => { + const api = getElectronAPI(); + const result = await api.autoMode.runFeature( + projectPath, + featureId, + useWorktrees, + worktreePath + ); + if (!result.success) { + throw new Error(result.error || 'Failed to start feature'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() }); + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) }); + }, + onError: (error: Error) => { + toast.error('Failed to start feature', { + description: error.message, + }); + }, + }); +} + +/** + * Resume a paused or interrupted feature + * + * @param projectPath - Path to the project + * @returns Mutation for resuming a feature + */ +export function useResumeFeature(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + featureId, + useWorktrees, + }: { + featureId: string; + useWorktrees?: boolean; + }) => { + const api = getElectronAPI(); + const result = await api.autoMode.resumeFeature(projectPath, featureId, useWorktrees); + if (!result.success) { + throw new Error(result.error || 'Failed to resume feature'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() }); + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) }); + }, + onError: (error: Error) => { + toast.error('Failed to resume feature', { + description: error.message, + }); + }, + }); +} + +/** + * Stop a running feature + * + * @returns Mutation for stopping a feature + */ +export function useStopFeature() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (featureId: string) => { + const api = getElectronAPI(); + const result = await api.autoMode.stopFeature(featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to stop feature'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() }); + toast.success('Feature stopped'); + }, + onError: (error: Error) => { + toast.error('Failed to stop feature', { + description: error.message, + }); + }, + }); +} + +/** + * Verify a completed feature + * + * @param projectPath - Path to the project + * @returns Mutation for verifying a feature + */ +export function useVerifyFeature(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (featureId: string) => { + const api = getElectronAPI(); + const result = await api.autoMode.verifyFeature(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to verify feature'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) }); + }, + onError: (error: Error) => { + toast.error('Failed to verify feature', { + description: error.message, + }); + }, + }); +} + +/** + * Approve or reject a plan + * + * @param projectPath - Path to the project + * @returns Mutation for plan approval + * + * @example + * ```tsx + * const approvePlan = useApprovePlan(projectPath); + * approvePlan.mutate({ featureId: 'abc', approved: true }); + * ``` + */ +export function useApprovePlan(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + featureId, + approved, + editedPlan, + feedback, + }: { + featureId: string; + approved: boolean; + editedPlan?: string; + feedback?: string; + }) => { + const api = getElectronAPI(); + const result = await api.autoMode.approvePlan( + projectPath, + featureId, + approved, + editedPlan, + feedback + ); + if (!result.success) { + throw new Error(result.error || 'Failed to submit plan decision'); + } + return result; + }, + onSuccess: (_, { approved }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) }); + if (approved) { + toast.success('Plan approved'); + } else { + toast.info('Plan rejected'); + } + }, + onError: (error: Error) => { + toast.error('Failed to submit plan decision', { + description: error.message, + }); + }, + }); +} + +/** + * Send a follow-up prompt to a feature + * + * @param projectPath - Path to the project + * @returns Mutation for sending follow-up + */ +export function useFollowUpFeature(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + featureId, + prompt, + imagePaths, + useWorktrees, + }: { + featureId: string; + prompt: string; + imagePaths?: string[]; + useWorktrees?: boolean; + }) => { + const api = getElectronAPI(); + const result = await api.autoMode.followUpFeature( + projectPath, + featureId, + prompt, + imagePaths, + useWorktrees + ); + if (!result.success) { + throw new Error(result.error || 'Failed to send follow-up'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() }); + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) }); + }, + onError: (error: Error) => { + toast.error('Failed to send follow-up', { + description: error.message, + }); + }, + }); +} + +/** + * Commit feature changes + * + * @param projectPath - Path to the project + * @returns Mutation for committing feature + */ +export function useCommitFeature(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (featureId: string) => { + const api = getElectronAPI(); + const result = await api.autoMode.commitFeature(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to commit changes'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) }); + queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) }); + toast.success('Changes committed'); + }, + onError: (error: Error) => { + toast.error('Failed to commit changes', { + description: error.message, + }); + }, + }); +} + +/** + * Analyze project structure + * + * @returns Mutation for project analysis + */ +export function useAnalyzeProject() { + return useMutation({ + mutationFn: async (projectPath: string) => { + const api = getElectronAPI(); + const result = await api.autoMode.analyzeProject(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to analyze project'); + } + return result; + }, + onSuccess: () => { + toast.success('Project analysis started'); + }, + onError: (error: Error) => { + toast.error('Failed to analyze project', { + description: error.message, + }); + }, + }); +} + +/** + * Start auto mode for all pending features + * + * @param projectPath - Path to the project + * @returns Mutation for starting auto mode + */ +export function useStartAutoMode(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (maxConcurrency?: number) => { + const api = getElectronAPI(); + const result = await api.autoMode.start(projectPath, maxConcurrency); + if (!result.success) { + throw new Error(result.error || 'Failed to start auto mode'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() }); + toast.success('Auto mode started'); + }, + onError: (error: Error) => { + toast.error('Failed to start auto mode', { + description: error.message, + }); + }, + }); +} + +/** + * Stop auto mode for all features + * + * @param projectPath - Path to the project + * @returns Mutation for stopping auto mode + */ +export function useStopAutoMode(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const api = getElectronAPI(); + const result = await api.autoMode.stop(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to stop auto mode'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() }); + toast.success('Auto mode stopped'); + }, + onError: (error: Error) => { + toast.error('Failed to stop auto mode', { + description: error.message, + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts b/apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts new file mode 100644 index 00000000..3b813d2e --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts @@ -0,0 +1,96 @@ +/** + * Cursor Permissions Mutation Hooks + * + * React Query mutations for managing Cursor CLI permissions. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; +import { toast } from 'sonner'; + +interface ApplyProfileInput { + profileId: 'strict' | 'development'; + scope: 'global' | 'project'; +} + +/** + * Apply a Cursor permission profile + * + * @param projectPath - Optional path to the project (required for project scope) + * @returns Mutation for applying permission profiles + * + * @example + * ```tsx + * const applyMutation = useApplyCursorProfile(projectPath); + * applyMutation.mutate({ profileId: 'development', scope: 'project' }); + * ``` + */ +export function useApplyCursorProfile(projectPath?: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: ApplyProfileInput) => { + const { profileId, scope } = input; + const api = getHttpApiClient(); + const result = await api.setup.applyCursorPermissionProfile( + profileId, + scope, + scope === 'project' ? projectPath : undefined + ); + + if (!result.success) { + throw new Error(result.error || 'Failed to apply profile'); + } + + return result; + }, + onSuccess: (result) => { + // Invalidate permissions cache + queryClient.invalidateQueries({ + queryKey: queryKeys.cursorPermissions.permissions(projectPath), + }); + toast.success(result.message || 'Profile applied'); + }, + onError: (error) => { + toast.error('Failed to apply profile', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + }, + }); +} + +/** + * Copy Cursor example config to clipboard + * + * @returns Mutation for copying config + * + * @example + * ```tsx + * const copyMutation = useCopyCursorConfig(); + * copyMutation.mutate('development'); + * ``` + */ +export function useCopyCursorConfig() { + return useMutation({ + mutationFn: async (profileId: 'strict' | 'development') => { + const api = getHttpApiClient(); + const result = await api.setup.getCursorExampleConfig(profileId); + + if (!result.success || !result.config) { + throw new Error(result.error || 'Failed to get config'); + } + + await navigator.clipboard.writeText(result.config); + return result; + }, + onSuccess: () => { + toast.success('Config copied to clipboard'); + }, + onError: (error) => { + toast.error('Failed to copy config', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-feature-mutations.ts b/apps/ui/src/hooks/mutations/use-feature-mutations.ts new file mode 100644 index 00000000..0b8c4e84 --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-feature-mutations.ts @@ -0,0 +1,267 @@ +/** + * Feature Mutations + * + * React Query mutations for creating, updating, and deleting features. + * Includes optimistic updates for better UX. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { toast } from 'sonner'; +import type { Feature } from '@/store/app-store'; + +/** + * Create a new feature + * + * @param projectPath - Path to the project + * @returns Mutation for creating a feature + * + * @example + * ```tsx + * const createFeature = useCreateFeature(projectPath); + * createFeature.mutate({ id: 'uuid', title: 'New Feature', ... }); + * ``` + */ +export function useCreateFeature(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (feature: Feature) => { + const api = getElectronAPI(); + const result = await api.features?.create(projectPath, feature); + if (!result?.success) { + throw new Error(result?.error || 'Failed to create feature'); + } + return result.feature; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + toast.success('Feature created'); + }, + onError: (error: Error) => { + toast.error('Failed to create feature', { + description: error.message, + }); + }, + }); +} + +/** + * Update an existing feature + * + * @param projectPath - Path to the project + * @returns Mutation for updating a feature with optimistic updates + */ +export function useUpdateFeature(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + featureId, + updates, + descriptionHistorySource, + enhancementMode, + preEnhancementDescription, + }: { + featureId: string; + updates: Partial; + descriptionHistorySource?: 'enhance' | 'edit'; + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'; + preEnhancementDescription?: string; + }) => { + const api = getElectronAPI(); + const result = await api.features?.update( + projectPath, + featureId, + updates, + descriptionHistorySource, + enhancementMode, + preEnhancementDescription + ); + if (!result?.success) { + throw new Error(result?.error || 'Failed to update feature'); + } + return result.feature; + }, + // Optimistic update + onMutate: async ({ featureId, updates }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + + // Snapshot the previous value + const previousFeatures = queryClient.getQueryData( + queryKeys.features.all(projectPath) + ); + + // Optimistically update the cache + if (previousFeatures) { + queryClient.setQueryData( + queryKeys.features.all(projectPath), + previousFeatures.map((f) => (f.id === featureId ? { ...f, ...updates } : f)) + ); + } + + return { previousFeatures }; + }, + onError: (error: Error, _, context) => { + // Rollback on error + if (context?.previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(projectPath), context.previousFeatures); + } + toast.error('Failed to update feature', { + description: error.message, + }); + }, + onSettled: () => { + // Always refetch after error or success + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + }, + }); +} + +/** + * Delete a feature + * + * @param projectPath - Path to the project + * @returns Mutation for deleting a feature with optimistic updates + */ +export function useDeleteFeature(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (featureId: string) => { + const api = getElectronAPI(); + const result = await api.features?.delete(projectPath, featureId); + if (!result?.success) { + throw new Error(result?.error || 'Failed to delete feature'); + } + }, + // Optimistic delete + onMutate: async (featureId) => { + await queryClient.cancelQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + + const previousFeatures = queryClient.getQueryData( + queryKeys.features.all(projectPath) + ); + + if (previousFeatures) { + queryClient.setQueryData( + queryKeys.features.all(projectPath), + previousFeatures.filter((f) => f.id !== featureId) + ); + } + + return { previousFeatures }; + }, + onError: (error: Error, _, context) => { + if (context?.previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(projectPath), context.previousFeatures); + } + toast.error('Failed to delete feature', { + description: error.message, + }); + }, + onSuccess: () => { + toast.success('Feature deleted'); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + }, + }); +} + +/** + * Generate a title for a feature description + * + * @returns Mutation for generating a title + */ +export function useGenerateTitle() { + return useMutation({ + mutationFn: async (description: string) => { + const api = getElectronAPI(); + const result = await api.features?.generateTitle(description); + if (!result?.success) { + throw new Error(result?.error || 'Failed to generate title'); + } + return result.title ?? ''; + }, + onError: (error: Error) => { + toast.error('Failed to generate title', { + description: error.message, + }); + }, + }); +} + +/** + * Batch update multiple features (for reordering) + * + * @param projectPath - Path to the project + * @returns Mutation for batch updating features + */ +export function useBatchUpdateFeatures(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (updates: Array<{ featureId: string; updates: Partial }>) => { + const api = getElectronAPI(); + const results = await Promise.all( + updates.map(({ featureId, updates: featureUpdates }) => + api.features?.update(projectPath, featureId, featureUpdates) + ) + ); + + const failed = results.filter((r) => !r?.success); + if (failed.length > 0) { + throw new Error(`Failed to update ${failed.length} features`); + } + }, + // Optimistic batch update + onMutate: async (updates) => { + await queryClient.cancelQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + + const previousFeatures = queryClient.getQueryData( + queryKeys.features.all(projectPath) + ); + + if (previousFeatures) { + const updatesMap = new Map(updates.map((u) => [u.featureId, u.updates])); + queryClient.setQueryData( + queryKeys.features.all(projectPath), + previousFeatures.map((f) => { + const featureUpdates = updatesMap.get(f.id); + return featureUpdates ? { ...f, ...featureUpdates } : f; + }) + ); + } + + return { previousFeatures }; + }, + onError: (error: Error, _, context) => { + if (context?.previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(projectPath), context.previousFeatures); + } + toast.error('Failed to update features', { + description: error.message, + }); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-github-mutations.ts b/apps/ui/src/hooks/mutations/use-github-mutations.ts new file mode 100644 index 00000000..4f4336ba --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-github-mutations.ts @@ -0,0 +1,159 @@ +/** + * GitHub Mutation Hooks + * + * React Query mutations for GitHub operations like validating issues. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getElectronAPI, GitHubIssue, GitHubComment } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { toast } from 'sonner'; +import type { LinkedPRInfo, ModelId } from '@automaker/types'; + +/** + * Input for validating a GitHub issue + */ +interface ValidateIssueInput { + issue: GitHubIssue; + model?: ModelId; + thinkingLevel?: number; + reasoningEffort?: string; + comments?: GitHubComment[]; + linkedPRs?: LinkedPRInfo[]; +} + +/** + * Validate a GitHub issue with AI + * + * This mutation triggers an async validation process. Results are delivered + * via WebSocket events (issue_validation_complete, issue_validation_error). + * + * @param projectPath - Path to the project + * @returns Mutation for validating issues + * + * @example + * ```tsx + * const validateMutation = useValidateIssue(projectPath); + * + * validateMutation.mutate({ + * issue, + * model: 'sonnet', + * comments, + * linkedPRs, + * }); + * ``` + */ +export function useValidateIssue(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: ValidateIssueInput) => { + const { issue, model, thinkingLevel, reasoningEffort, comments, linkedPRs } = input; + + const api = getElectronAPI(); + if (!api.github?.validateIssue) { + throw new Error('Validation API not available'); + } + + const validationInput = { + issueNumber: issue.number, + issueTitle: issue.title, + issueBody: issue.body || '', + issueLabels: issue.labels.map((l) => l.name), + comments, + linkedPRs, + }; + + const result = await api.github.validateIssue( + projectPath, + validationInput, + model, + thinkingLevel, + reasoningEffort + ); + + if (!result.success) { + throw new Error(result.error || 'Failed to start validation'); + } + + return { issueNumber: issue.number }; + }, + onSuccess: (_, variables) => { + toast.info(`Starting validation for issue #${variables.issue.number}`, { + description: 'You will be notified when the analysis is complete', + }); + }, + onError: (error) => { + toast.error('Failed to validate issue', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + }, + // Note: We don't invalidate queries here because the actual result + // comes through WebSocket events which handle cache invalidation + }); +} + +/** + * Mark a validation as viewed + * + * @param projectPath - Path to the project + * @returns Mutation for marking validation as viewed + * + * @example + * ```tsx + * const markViewedMutation = useMarkValidationViewed(projectPath); + * markViewedMutation.mutate(issueNumber); + * ``` + */ +export function useMarkValidationViewed(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (issueNumber: number) => { + const api = getElectronAPI(); + if (!api.github?.markValidationViewed) { + throw new Error('Mark viewed API not available'); + } + + const result = await api.github.markValidationViewed(projectPath, issueNumber); + + if (!result.success) { + throw new Error(result.error || 'Failed to mark as viewed'); + } + + return { issueNumber }; + }, + onSuccess: () => { + // Invalidate validations cache to refresh the viewed state + queryClient.invalidateQueries({ + queryKey: queryKeys.github.validations(projectPath), + }); + }, + // Silent mutation - no toast needed for marking as viewed + }); +} + +/** + * Get running validation status + * + * @param projectPath - Path to the project + * @returns Mutation for getting validation status (returns running issue numbers) + */ +export function useGetValidationStatus(projectPath: string) { + return useMutation({ + mutationFn: async () => { + const api = getElectronAPI(); + if (!api.github?.getValidationStatus) { + throw new Error('Validation status API not available'); + } + + const result = await api.github.getValidationStatus(projectPath); + + if (!result.success) { + throw new Error(result.error || 'Failed to get validation status'); + } + + return result.runningIssues ?? []; + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-ideation-mutations.ts b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts new file mode 100644 index 00000000..61841d9e --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts @@ -0,0 +1,82 @@ +/** + * Ideation Mutation Hooks + * + * React Query mutations for ideation operations like generating suggestions. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { toast } from 'sonner'; +import type { IdeaCategory, IdeaSuggestion } from '@automaker/types'; + +/** + * Input for generating ideation suggestions + */ +interface GenerateSuggestionsInput { + promptId: string; + category: IdeaCategory; +} + +/** + * Result from generating suggestions + */ +interface GenerateSuggestionsResult { + suggestions: IdeaSuggestion[]; + promptId: string; + category: IdeaCategory; +} + +/** + * Generate ideation suggestions based on a prompt + * + * @param projectPath - Path to the project + * @returns Mutation for generating suggestions + * + * @example + * ```tsx + * const generateMutation = useGenerateIdeationSuggestions(projectPath); + * + * generateMutation.mutate({ + * promptId: 'prompt-1', + * category: 'ux', + * }, { + * onSuccess: (data) => { + * console.log('Generated', data.suggestions.length, 'suggestions'); + * }, + * }); + * ``` + */ +export function useGenerateIdeationSuggestions(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: GenerateSuggestionsInput): Promise => { + const { promptId, category } = input; + + const api = getElectronAPI(); + if (!api.ideation?.generateSuggestions) { + throw new Error('Ideation API not available'); + } + + const result = await api.ideation.generateSuggestions(projectPath, promptId, category); + + if (!result.success) { + throw new Error(result.error || 'Failed to generate suggestions'); + } + + return { + suggestions: result.suggestions ?? [], + promptId, + category, + }; + }, + onSuccess: () => { + // Invalidate ideation ideas cache + queryClient.invalidateQueries({ + queryKey: queryKeys.ideation.ideas(projectPath), + }); + }, + // Toast notifications are handled by the component since it has access to prompt title + }); +} diff --git a/apps/ui/src/hooks/mutations/use-settings-mutations.ts b/apps/ui/src/hooks/mutations/use-settings-mutations.ts new file mode 100644 index 00000000..aa1862ed --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-settings-mutations.ts @@ -0,0 +1,144 @@ +/** + * Settings Mutations + * + * React Query mutations for updating global and project settings. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { toast } from 'sonner'; + +interface UpdateGlobalSettingsOptions { + /** Show success toast (default: true) */ + showSuccessToast?: boolean; +} + +/** + * Update global settings + * + * @param options - Configuration options + * @returns Mutation for updating global settings + * + * @example + * ```tsx + * const mutation = useUpdateGlobalSettings(); + * mutation.mutate({ enableSkills: true }); + * + * // With custom success handling (no default toast) + * const mutation = useUpdateGlobalSettings({ showSuccessToast: false }); + * mutation.mutate({ enableSkills: true }, { + * onSuccess: () => toast.success('Skills enabled'), + * }); + * ``` + */ +export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {}) { + const { showSuccessToast = true } = options; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (settings: Record) => { + const api = getElectronAPI(); + // Use updateGlobal for partial updates + const result = await api.settings.updateGlobal(settings); + if (!result.success) { + throw new Error(result.error || 'Failed to update settings'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.settings.global() }); + if (showSuccessToast) { + toast.success('Settings saved'); + } + }, + onError: (error: Error) => { + toast.error('Failed to save settings', { + description: error.message, + }); + }, + }); +} + +/** + * Update project settings + * + * @param projectPath - Optional path to the project (can also pass via mutation variables) + * @returns Mutation for updating project settings + */ +export function useUpdateProjectSettings(projectPath?: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + variables: + | Record + | { projectPath: string; settings: Record } + ) => { + // Support both call patterns: + // 1. useUpdateProjectSettings(projectPath) then mutate(settings) + // 2. useUpdateProjectSettings() then mutate({ projectPath, settings }) + let path: string; + let settings: Record; + + if ('projectPath' in variables && 'settings' in variables) { + path = variables.projectPath; + settings = variables.settings; + } else if (projectPath) { + path = projectPath; + settings = variables; + } else { + throw new Error('Project path is required'); + } + + const api = getElectronAPI(); + const result = await api.settings.setProject(path, settings); + if (!result.success) { + throw new Error(result.error || 'Failed to update project settings'); + } + return { ...result, projectPath: path }; + }, + onSuccess: (data) => { + const path = data.projectPath || projectPath; + if (path) { + queryClient.invalidateQueries({ queryKey: queryKeys.settings.project(path) }); + } + toast.success('Project settings saved'); + }, + onError: (error: Error) => { + toast.error('Failed to save project settings', { + description: error.message, + }); + }, + }); +} + +/** + * Save credentials (API keys) + * + * @returns Mutation for saving credentials + */ +export function useSaveCredentials() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (credentials: Record) => { + const api = getElectronAPI(); + const result = await api.settings.setCredentials(credentials); + if (!result.success) { + throw new Error(result.error || 'Failed to save credentials'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.settings.credentials() }); + queryClient.invalidateQueries({ queryKey: queryKeys.cli.apiKeys() }); + toast.success('Credentials saved'); + }, + onError: (error: Error) => { + toast.error('Failed to save credentials', { + description: error.message, + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-spec-mutations.ts b/apps/ui/src/hooks/mutations/use-spec-mutations.ts new file mode 100644 index 00000000..98279d65 --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-spec-mutations.ts @@ -0,0 +1,179 @@ +/** + * Spec Mutation Hooks + * + * React Query mutations for spec operations like creating, regenerating, and saving. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { toast } from 'sonner'; +import type { FeatureCount } from '@/components/views/spec-view/types'; + +/** + * Input for creating a spec + */ +interface CreateSpecInput { + projectOverview: string; + generateFeatures: boolean; + analyzeProject: boolean; + featureCount?: FeatureCount; +} + +/** + * Input for regenerating a spec + */ +interface RegenerateSpecInput { + projectDefinition: string; + generateFeatures: boolean; + analyzeProject: boolean; + featureCount?: FeatureCount; +} + +/** + * Create a new spec for a project + * + * This mutation triggers an async spec creation process. Progress and completion + * are delivered via WebSocket events (spec_regeneration_progress, spec_regeneration_complete). + * + * @param projectPath - Path to the project + * @returns Mutation for creating specs + * + * @example + * ```tsx + * const createMutation = useCreateSpec(projectPath); + * + * createMutation.mutate({ + * projectOverview: 'A todo app with...', + * generateFeatures: true, + * analyzeProject: true, + * featureCount: 50, + * }); + * ``` + */ +export function useCreateSpec(projectPath: string) { + return useMutation({ + mutationFn: async (input: CreateSpecInput) => { + const { projectOverview, generateFeatures, analyzeProject, featureCount } = input; + + const api = getElectronAPI(); + if (!api.specRegeneration) { + throw new Error('Spec regeneration API not available'); + } + + const result = await api.specRegeneration.create( + projectPath, + projectOverview.trim(), + generateFeatures, + analyzeProject, + generateFeatures ? featureCount : undefined + ); + + if (!result.success) { + throw new Error(result.error || 'Failed to start spec creation'); + } + + return result; + }, + // Toast/state updates are handled by the component since it tracks WebSocket events + }); +} + +/** + * Regenerate an existing spec + * + * @param projectPath - Path to the project + * @returns Mutation for regenerating specs + */ +export function useRegenerateSpec(projectPath: string) { + return useMutation({ + mutationFn: async (input: RegenerateSpecInput) => { + const { projectDefinition, generateFeatures, analyzeProject, featureCount } = input; + + const api = getElectronAPI(); + if (!api.specRegeneration) { + throw new Error('Spec regeneration API not available'); + } + + const result = await api.specRegeneration.generate( + projectPath, + projectDefinition.trim(), + generateFeatures, + analyzeProject, + generateFeatures ? featureCount : undefined + ); + + if (!result.success) { + throw new Error(result.error || 'Failed to start spec regeneration'); + } + + return result; + }, + }); +} + +/** + * Generate features from existing spec + * + * @param projectPath - Path to the project + * @returns Mutation for generating features + */ +export function useGenerateFeatures(projectPath: string) { + return useMutation({ + mutationFn: async () => { + const api = getElectronAPI(); + if (!api.specRegeneration) { + throw new Error('Spec regeneration API not available'); + } + + const result = await api.specRegeneration.generateFeatures(projectPath); + + if (!result.success) { + throw new Error(result.error || 'Failed to start feature generation'); + } + + return result; + }, + }); +} + +/** + * Save spec file content + * + * @param projectPath - Path to the project + * @returns Mutation for saving spec + * + * @example + * ```tsx + * const saveMutation = useSaveSpec(projectPath); + * + * saveMutation.mutate(specContent, { + * onSuccess: () => setHasChanges(false), + * }); + * ``` + */ +export function useSaveSpec(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (content: string) => { + const api = getElectronAPI(); + + await api.writeFile(`${projectPath}/.automaker/app_spec.txt`, content); + + return { content }; + }, + onSuccess: () => { + // Invalidate spec file cache + queryClient.invalidateQueries({ + queryKey: queryKeys.spec.file(projectPath), + }); + toast.success('Spec saved'); + }, + onError: (error) => { + toast.error('Failed to save spec', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-worktree-mutations.ts b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts new file mode 100644 index 00000000..ec8dd6e0 --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts @@ -0,0 +1,480 @@ +/** + * Worktree Mutations + * + * React Query mutations for worktree operations like creating, deleting, + * committing, pushing, and creating pull requests. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { toast } from 'sonner'; + +/** + * Create a new worktree + * + * @param projectPath - Path to the project + * @returns Mutation for creating a worktree + */ +export function useCreateWorktree(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ branchName, baseBranch }: { branchName: string; baseBranch?: string }) => { + const api = getElectronAPI(); + const result = await api.worktree.create(projectPath, branchName, baseBranch); + if (!result.success) { + throw new Error(result.error || 'Failed to create worktree'); + } + return result.worktree; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) }); + toast.success('Worktree created'); + }, + onError: (error: Error) => { + toast.error('Failed to create worktree', { + description: error.message, + }); + }, + }); +} + +/** + * Delete a worktree + * + * @param projectPath - Path to the project + * @returns Mutation for deleting a worktree + */ +export function useDeleteWorktree(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + worktreePath, + deleteBranch, + }: { + worktreePath: string; + deleteBranch?: boolean; + }) => { + const api = getElectronAPI(); + const result = await api.worktree.delete(projectPath, worktreePath, deleteBranch); + if (!result.success) { + throw new Error(result.error || 'Failed to delete worktree'); + } + return result.deleted; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) }); + toast.success('Worktree deleted'); + }, + onError: (error: Error) => { + toast.error('Failed to delete worktree', { + description: error.message, + }); + }, + }); +} + +/** + * Commit changes in a worktree + * + * @returns Mutation for committing changes + */ +export function useCommitWorktree() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ worktreePath, message }: { worktreePath: string; message: string }) => { + const api = getElectronAPI(); + const result = await api.worktree.commit(worktreePath, message); + if (!result.success) { + throw new Error(result.error || 'Failed to commit changes'); + } + return result.result; + }, + onSuccess: (_, { worktreePath }) => { + // Invalidate all worktree queries since we don't know the project path + queryClient.invalidateQueries({ queryKey: ['worktrees'] }); + toast.success('Changes committed'); + }, + onError: (error: Error) => { + toast.error('Failed to commit changes', { + description: error.message, + }); + }, + }); +} + +/** + * Push worktree branch to remote + * + * @returns Mutation for pushing changes + */ +export function usePushWorktree() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ worktreePath, force }: { worktreePath: string; force?: boolean }) => { + const api = getElectronAPI(); + const result = await api.worktree.push(worktreePath, force); + if (!result.success) { + throw new Error(result.error || 'Failed to push changes'); + } + return result.result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['worktrees'] }); + toast.success('Changes pushed to remote'); + }, + onError: (error: Error) => { + toast.error('Failed to push changes', { + description: error.message, + }); + }, + }); +} + +/** + * Pull changes from remote + * + * @returns Mutation for pulling changes + */ +export function usePullWorktree() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (worktreePath: string) => { + const api = getElectronAPI(); + const result = await api.worktree.pull(worktreePath); + if (!result.success) { + throw new Error(result.error || 'Failed to pull changes'); + } + return result.result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['worktrees'] }); + toast.success('Changes pulled from remote'); + }, + onError: (error: Error) => { + toast.error('Failed to pull changes', { + description: error.message, + }); + }, + }); +} + +/** + * Create a pull request from a worktree + * + * @returns Mutation for creating a PR + */ +export function useCreatePullRequest() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + worktreePath, + options, + }: { + worktreePath: string; + options?: { + projectPath?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + baseBranch?: string; + draft?: boolean; + }; + }) => { + const api = getElectronAPI(); + const result = await api.worktree.createPR(worktreePath, options); + if (!result.success) { + throw new Error(result.error || 'Failed to create pull request'); + } + return result.result; + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['worktrees'] }); + queryClient.invalidateQueries({ queryKey: ['github', 'prs'] }); + if (result?.prUrl) { + toast.success('Pull request created', { + description: `PR #${result.prNumber} created`, + action: { + label: 'Open', + onClick: () => { + const api = getElectronAPI(); + api.openExternalLink(result.prUrl!); + }, + }, + }); + } else if (result?.prAlreadyExisted) { + toast.info('Pull request already exists'); + } + }, + onError: (error: Error) => { + toast.error('Failed to create pull request', { + description: error.message, + }); + }, + }); +} + +/** + * Merge a worktree branch into main + * + * @param projectPath - Path to the project + * @returns Mutation for merging a feature + */ +export function useMergeWorktree(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + branchName, + worktreePath, + options, + }: { + branchName: string; + worktreePath: string; + options?: { + squash?: boolean; + message?: string; + }; + }) => { + const api = getElectronAPI(); + const result = await api.worktree.mergeFeature( + projectPath, + branchName, + worktreePath, + options + ); + if (!result.success) { + throw new Error(result.error || 'Failed to merge feature'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) }); + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) }); + toast.success('Feature merged successfully'); + }, + onError: (error: Error) => { + toast.error('Failed to merge feature', { + description: error.message, + }); + }, + }); +} + +/** + * Switch to a different branch + * + * @returns Mutation for switching branches + */ +export function useSwitchBranch() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + worktreePath, + branchName, + }: { + worktreePath: string; + branchName: string; + }) => { + const api = getElectronAPI(); + const result = await api.worktree.switchBranch(worktreePath, branchName); + if (!result.success) { + throw new Error(result.error || 'Failed to switch branch'); + } + return result.result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['worktrees'] }); + toast.success('Switched branch'); + }, + onError: (error: Error) => { + toast.error('Failed to switch branch', { + description: error.message, + }); + }, + }); +} + +/** + * Checkout a new branch + * + * @returns Mutation for creating and checking out a new branch + */ +export function useCheckoutBranch() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + worktreePath, + branchName, + }: { + worktreePath: string; + branchName: string; + }) => { + const api = getElectronAPI(); + const result = await api.worktree.checkoutBranch(worktreePath, branchName); + if (!result.success) { + throw new Error(result.error || 'Failed to checkout branch'); + } + return result.result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['worktrees'] }); + toast.success('New branch created and checked out'); + }, + onError: (error: Error) => { + toast.error('Failed to checkout branch', { + description: error.message, + }); + }, + }); +} + +/** + * Generate a commit message from git diff + * + * @returns Mutation for generating a commit message + */ +export function useGenerateCommitMessage() { + return useMutation({ + mutationFn: async (worktreePath: string) => { + const api = getElectronAPI(); + const result = await api.worktree.generateCommitMessage(worktreePath); + if (!result.success) { + throw new Error(result.error || 'Failed to generate commit message'); + } + return result.message ?? ''; + }, + onError: (error: Error) => { + toast.error('Failed to generate commit message', { + description: error.message, + }); + }, + }); +} + +/** + * Open worktree in editor + * + * @returns Mutation for opening in editor + */ +export function useOpenInEditor() { + return useMutation({ + mutationFn: async ({ + worktreePath, + editorCommand, + }: { + worktreePath: string; + editorCommand?: string; + }) => { + const api = getElectronAPI(); + const result = await api.worktree.openInEditor(worktreePath, editorCommand); + if (!result.success) { + throw new Error(result.error || 'Failed to open in editor'); + } + return result.result; + }, + onError: (error: Error) => { + toast.error('Failed to open in editor', { + description: error.message, + }); + }, + }); +} + +/** + * Initialize git in a project + * + * @returns Mutation for initializing git + */ +export function useInitGit() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (projectPath: string) => { + const api = getElectronAPI(); + const result = await api.worktree.initGit(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to initialize git'); + } + return result.result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['worktrees'] }); + queryClient.invalidateQueries({ queryKey: ['github'] }); + toast.success('Git repository initialized'); + }, + onError: (error: Error) => { + toast.error('Failed to initialize git', { + description: error.message, + }); + }, + }); +} + +/** + * Set init script for a project + * + * @param projectPath - Path to the project + * @returns Mutation for setting init script + */ +export function useSetInitScript(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (content: string) => { + const api = getElectronAPI(); + const result = await api.worktree.setInitScript(projectPath, content); + if (!result.success) { + throw new Error(result.error || 'Failed to save init script'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.initScript(projectPath) }); + toast.success('Init script saved'); + }, + onError: (error: Error) => { + toast.error('Failed to save init script', { + description: error.message, + }); + }, + }); +} + +/** + * Delete init script for a project + * + * @param projectPath - Path to the project + * @returns Mutation for deleting init script + */ +export function useDeleteInitScript(projectPath: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const api = getElectronAPI(); + const result = await api.worktree.deleteInitScript(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to delete init script'); + } + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.initScript(projectPath) }); + toast.success('Init script deleted'); + }, + onError: (error: Error) => { + toast.error('Failed to delete init script', { + description: error.message, + }); + }, + }); +} From d81997d24b6c01ca8bbda9066baa782e3b2994ee Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:20:53 +0100 Subject: [PATCH 04/76] feat(ui): add WebSocket event to React Query cache bridge - Add useAutoModeQueryInvalidation for feature/agent events - Add useSpecRegenerationQueryInvalidation for spec updates - Add useGitHubValidationQueryInvalidation for PR validation events - Bridge WebSocket events to cache invalidation for real-time updates Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/hooks/use-query-invalidation.ts | 228 ++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 apps/ui/src/hooks/use-query-invalidation.ts diff --git a/apps/ui/src/hooks/use-query-invalidation.ts b/apps/ui/src/hooks/use-query-invalidation.ts new file mode 100644 index 00000000..4d8878da --- /dev/null +++ b/apps/ui/src/hooks/use-query-invalidation.ts @@ -0,0 +1,228 @@ +/** + * Query Invalidation Hooks + * + * These hooks connect WebSocket events to React Query cache invalidation, + * ensuring the UI stays in sync with server-side changes without manual refetching. + */ + +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import type { AutoModeEvent, SpecRegenerationEvent } from '@/types/electron'; +import type { IssueValidationEvent } from '@automaker/types'; + +/** + * Invalidate queries based on auto mode events + * + * This hook subscribes to auto mode events (feature start, complete, error, etc.) + * and invalidates relevant queries to keep the UI in sync. + * + * @param projectPath - Current project path + * + * @example + * ```tsx + * function BoardView() { + * const projectPath = useAppStore(s => s.currentProject?.path); + * useAutoModeQueryInvalidation(projectPath); + * // ... + * } + * ``` + */ +export function useAutoModeQueryInvalidation(projectPath: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!projectPath) return; + + const api = getElectronAPI(); + const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { + // Invalidate features when agent completes, errors, or receives plan approval + if ( + event.type === 'auto_mode_feature_complete' || + event.type === 'auto_mode_error' || + event.type === 'plan_approval_required' || + event.type === 'plan_approved' || + event.type === 'plan_rejected' || + event.type === 'pipeline_step_complete' + ) { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + } + + // Invalidate running agents on any status change + if ( + event.type === 'auto_mode_feature_start' || + event.type === 'auto_mode_feature_complete' || + event.type === 'auto_mode_error' || + event.type === 'auto_mode_resuming_features' + ) { + queryClient.invalidateQueries({ + queryKey: queryKeys.runningAgents.all(), + }); + } + + // Invalidate specific feature when it starts or has phase changes + if ( + (event.type === 'auto_mode_feature_start' || + event.type === 'auto_mode_phase' || + event.type === 'auto_mode_phase_complete' || + event.type === 'pipeline_step_started') && + 'featureId' in event + ) { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.single(projectPath, event.featureId), + }); + } + + // Invalidate agent output during progress updates + if (event.type === 'auto_mode_progress' && 'featureId' in event) { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.agentOutput(projectPath, event.featureId), + }); + } + + // Invalidate worktree queries when feature completes (may have created worktree) + if (event.type === 'auto_mode_feature_complete' && 'featureId' in event) { + queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.all(projectPath), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.single(projectPath, event.featureId), + }); + } + }); + + return unsubscribe; + }, [projectPath, queryClient]); +} + +/** + * Invalidate queries based on spec regeneration events + * + * @param projectPath - Current project path + */ +export function useSpecRegenerationQueryInvalidation(projectPath: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!projectPath) return; + + const api = getElectronAPI(); + const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => { + // Only handle events for the current project + if (event.projectPath !== projectPath) return; + + if (event.type === 'spec_regeneration_complete') { + // Invalidate features as new ones may have been generated + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + + // Invalidate spec regeneration status + queryClient.invalidateQueries({ + queryKey: queryKeys.specRegeneration.status(projectPath), + }); + } + }); + + return unsubscribe; + }, [projectPath, queryClient]); +} + +/** + * Invalidate queries based on GitHub validation events + * + * @param projectPath - Current project path + */ +export function useGitHubValidationQueryInvalidation(projectPath: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!projectPath) return; + + const api = getElectronAPI(); + const unsubscribe = api.github?.onValidationEvent((event: IssueValidationEvent) => { + if (event.type === 'validation_complete' || event.type === 'validation_error') { + // Invalidate all validations for this project + queryClient.invalidateQueries({ + queryKey: queryKeys.github.validations(projectPath), + }); + + // Also invalidate specific issue validation if we have the issue number + if ('issueNumber' in event && event.issueNumber) { + queryClient.invalidateQueries({ + queryKey: queryKeys.github.validation(projectPath, event.issueNumber), + }); + } + } + }); + + return unsubscribe; + }, [projectPath, queryClient]); +} + +/** + * Invalidate session queries based on agent stream events + * + * @param sessionId - Current session ID + */ +export function useSessionQueryInvalidation(sessionId: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!sessionId) return; + + const api = getElectronAPI(); + const unsubscribe = api.agent.onStream((event) => { + // Only handle events for the current session + if ('sessionId' in event && event.sessionId !== sessionId) return; + + // Invalidate session history when a message is complete + if (event.type === 'complete' || event.type === 'message') { + queryClient.invalidateQueries({ + queryKey: queryKeys.sessions.history(sessionId), + }); + } + + // Invalidate sessions list when any session changes + if (event.type === 'complete') { + queryClient.invalidateQueries({ + queryKey: queryKeys.sessions.all(), + }); + } + }); + + return unsubscribe; + }, [sessionId, queryClient]); +} + +/** + * Combined hook that sets up all query invalidation subscriptions + * + * Use this hook at the app root or in a layout component to ensure + * all WebSocket events properly invalidate React Query caches. + * + * @param projectPath - Current project path + * @param sessionId - Current session ID (optional) + * + * @example + * ```tsx + * function AppLayout() { + * const projectPath = useAppStore(s => s.currentProject?.path); + * const sessionId = useAppStore(s => s.currentSessionId); + * useQueryInvalidation(projectPath, sessionId); + * // ... + * } + * ``` + */ +export function useQueryInvalidation( + projectPath: string | undefined, + sessionId?: string | undefined +) { + useAutoModeQueryInvalidation(projectPath); + useSpecRegenerationQueryInvalidation(projectPath); + useGitHubValidationQueryInvalidation(projectPath); + useSessionQueryInvalidation(sessionId); +} From d08ef472a304b5909bc49f49e0567578bf7485a8 Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:21:08 +0100 Subject: [PATCH 05/76] feat(ui): add shared skeleton component and update CLI status - Add reusable SkeletonPulse component to replace 4 duplicate definitions - Update CLI status components to use shared skeleton - Simplify CLI status components by using React Query hooks Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/components/ui/skeleton.tsx | 18 ++++++++++++++++++ .../cli-status/claude-cli-status.tsx | 5 +---- .../cli-status/codex-cli-status.tsx | 5 +---- .../cli-status/cursor-cli-status.tsx | 5 +---- .../cli-status/opencode-cli-status.tsx | 5 +---- 5 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 apps/ui/src/components/ui/skeleton.tsx diff --git a/apps/ui/src/components/ui/skeleton.tsx b/apps/ui/src/components/ui/skeleton.tsx new file mode 100644 index 00000000..0efc029a --- /dev/null +++ b/apps/ui/src/components/ui/skeleton.tsx @@ -0,0 +1,18 @@ +/** + * Skeleton Components + * + * Loading placeholder components for content that's being fetched. + */ + +import { cn } from '@/lib/utils'; + +interface SkeletonPulseProps { + className?: string; +} + +/** + * Pulsing skeleton placeholder for loading states + */ +export function SkeletonPulse({ className }: SkeletonPulseProps) { + return
; +} diff --git a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx index 2457969b..d4ad50c5 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; +import { SkeletonPulse } from '@/components/ui/skeleton'; import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; @@ -34,10 +35,6 @@ function getAuthMethodLabel(method: string): string { } } -function SkeletonPulse({ className }: { className?: string }) { - return
; -} - function ClaudeCliStatusSkeleton() { return (
; -} - function CodexCliStatusSkeleton() { return (
void; } -function SkeletonPulse({ className }: { className?: string }) { - return
; -} - export function CursorCliStatusSkeleton() { return (
void; } -function SkeletonPulse({ className }: { className?: string }) { - return
; -} - export function OpencodeCliStatusSkeleton() { return (
Date: Thu, 15 Jan 2026 16:21:23 +0100 Subject: [PATCH 06/76] refactor(ui): migrate board view to React Query - Replace manual fetching in use-board-features with useFeatures query - Migrate use-board-actions to use mutation hooks - Update kanban-card and agent-info-panel to use query hooks - Migrate agent-output-modal to useAgentOutput query - Migrate create-pr-dialog to useCreatePR mutation - Remove manual loading/error state management - Add proper cache invalidation on mutations Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/components/views/board-view.tsx | 36 +-- .../kanban-card/agent-info-panel.tsx | 115 ++++------ .../components/kanban-card/kanban-card.tsx | 3 +- .../board-view/dialogs/agent-output-modal.tsx | 81 +++---- .../board-view/dialogs/create-pr-dialog.tsx | 51 ++--- .../board-view/hooks/use-board-actions.ts | 63 +----- .../board-view/hooks/use-board-features.ts | 213 +++++------------- 7 files changed, 160 insertions(+), 402 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2dd705b9..6462b092 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -79,6 +79,9 @@ import { SelectionActionBar, ListView } from './board-view/components'; import { MassEditDialog } from './board-view/dialogs'; import { InitScriptIndicator } from './board-view/init-script-indicator'; import { useInitScriptEvents } from '@/hooks/use-init-script-events'; +import { usePipelineConfig } from '@/hooks/queries'; +import { useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/query-keys'; // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; @@ -109,8 +112,9 @@ export function BoardView() { getPrimaryWorktreeBranch, setPipelineConfig, } = useAppStore(); - // Subscribe to pipelineConfigByProject to trigger re-renders when it changes - const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject); + // Fetch pipeline config via React Query + const { data: pipelineConfig } = usePipelineConfig(currentProject?.path); + const queryClient = useQueryClient(); // Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); // Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes @@ -241,25 +245,6 @@ export function BoardView() { setFeaturesWithContext, }); - // Load pipeline config when project changes - useEffect(() => { - if (!currentProject?.path) return; - - const loadPipelineConfig = async () => { - try { - const api = getHttpApiClient(); - const result = await api.pipeline.getConfig(currentProject.path); - if (result.success && result.config) { - setPipelineConfig(currentProject.path, result.config); - } - } catch (error) { - logger.error('Failed to load pipeline config:', error); - } - }; - - loadPipelineConfig(); - }, [currentProject?.path, setPipelineConfig]); - // Auto mode hook const autoMode = useAutoMode(); // Get runningTasks from the hook (scoped to current project) @@ -1131,9 +1116,7 @@ export function BoardView() { }); // Build columnFeaturesMap for ListView - const pipelineConfig = currentProject?.path - ? pipelineConfigByProject[currentProject.path] || null - : null; + // pipelineConfig is now from usePipelineConfig React Query hook at the top const columnFeaturesMap = useMemo(() => { const columns = getColumnsWithPipeline(pipelineConfig); const map: Record = {}; @@ -1585,6 +1568,11 @@ export function BoardView() { if (!result.success) { throw new Error(result.error || 'Failed to save pipeline config'); } + // Invalidate React Query cache to refetch updated config + queryClient.invalidateQueries({ + queryKey: queryKeys.pipeline.config(currentProject.path), + }); + // Also update Zustand for backward compatibility setPipelineConfig(currentProject.path, config); }} /> diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 6916222e..2d3edd23 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useEffect, useState, useMemo } from 'react'; import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store'; import type { ReasoningEffort } from '@automaker/types'; @@ -24,6 +23,7 @@ import { import { getElectronAPI } from '@/lib/electron'; import { SummaryDialog } from './summary-dialog'; import { getProviderIconForModel } from '@/components/ui/provider-icon'; +import { useFeature, useAgentOutput } from '@/hooks/queries'; /** * Formats thinking level for compact display @@ -58,6 +58,7 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string { interface AgentInfoPanelProps { feature: Feature; + projectPath: string; contextContent?: string; summary?: string; isCurrentAutoTask?: boolean; @@ -65,23 +66,54 @@ interface AgentInfoPanelProps { export function AgentInfoPanel({ feature, + projectPath, contextContent, summary, isCurrentAutoTask, }: AgentInfoPanelProps) { - const [agentInfo, setAgentInfo] = useState(null); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); const [isTodosExpanded, setIsTodosExpanded] = useState(false); // Track real-time task status updates from WebSocket events const [taskStatusMap, setTaskStatusMap] = useState< Map >(new Map()); - // Fresh planSpec data fetched from API (store data is stale for task progress) - const [freshPlanSpec, setFreshPlanSpec] = useState<{ - tasks?: ParsedTask[]; - tasksCompleted?: number; - currentTaskId?: string; - } | null>(null); + + // Determine if we should poll for updates + const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress'; + const shouldFetchData = feature.status !== 'backlog'; + + // Fetch fresh feature data for planSpec (store data can be stale for task progress) + const { data: freshFeature } = useFeature(projectPath, feature.id, { + enabled: shouldFetchData && !contextContent, + pollingInterval: shouldPoll ? 3000 : false, + }); + + // Fetch agent output for parsing + const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, { + enabled: shouldFetchData && !contextContent, + pollingInterval: shouldPoll ? 3000 : false, + }); + + // Parse agent output into agentInfo + const agentInfo = useMemo(() => { + if (contextContent) { + return parseAgentContext(contextContent); + } + if (agentOutputContent) { + return parseAgentContext(agentOutputContent); + } + return null; + }, [contextContent, agentOutputContent]); + + // Fresh planSpec data from API (more accurate than store data for task progress) + const freshPlanSpec = useMemo(() => { + if (!freshFeature?.planSpec) return null; + return { + tasks: freshFeature.planSpec.tasks, + tasksCompleted: freshFeature.planSpec.tasksCompleted || 0, + currentTaskId: freshFeature.planSpec.currentTaskId, + }; + }, [freshFeature?.planSpec]); // Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos // Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates @@ -133,73 +165,6 @@ export function AgentInfoPanel({ taskStatusMap, ]); - useEffect(() => { - const loadContext = async () => { - if (contextContent) { - const info = parseAgentContext(contextContent); - setAgentInfo(info); - return; - } - - if (feature.status === 'backlog') { - setAgentInfo(null); - setFreshPlanSpec(null); - return; - } - - try { - const api = getElectronAPI(); - const currentProject = (window as any).__currentProject; - if (!currentProject?.path) return; - - if (api.features) { - // Fetch fresh feature data to get up-to-date planSpec (store data is stale) - try { - const featureResult = await api.features.get(currentProject.path, feature.id); - const freshFeature: any = (featureResult as any).feature; - if (featureResult.success && freshFeature?.planSpec) { - setFreshPlanSpec({ - tasks: freshFeature.planSpec.tasks, - tasksCompleted: freshFeature.planSpec.tasksCompleted || 0, - currentTaskId: freshFeature.planSpec.currentTaskId, - }); - } - } catch { - // Ignore errors fetching fresh planSpec - } - - const result = await api.features.getAgentOutput(currentProject.path, feature.id); - - if (result.success && result.content) { - const info = parseAgentContext(result.content); - setAgentInfo(info); - } - } else { - const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`; - const result = await api.readFile(contextPath); - - if (result.success && result.content) { - const info = parseAgentContext(result.content); - setAgentInfo(info); - } - } - } catch { - console.debug('[KanbanCard] No context file for feature:', feature.id); - } - }; - - loadContext(); - - // Poll for updates when feature is in_progress (not just isCurrentAutoTask) - // This ensures planSpec progress stays in sync - if (isCurrentAutoTask || feature.status === 'in_progress') { - const interval = setInterval(loadContext, 3000); - return () => { - clearInterval(interval); - }; - } - }, [feature.id, feature.status, contextContent, isCurrentAutoTask]); - // Listen to WebSocket events for real-time task status updates // This ensures the Kanban card shows the same progress as the Agent Output modal // Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index 6f22e87e..a2845a3d 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -97,7 +97,7 @@ export const KanbanCard = memo(function KanbanCard({ isSelected = false, onToggleSelect, }: KanbanCardProps) { - const { useWorktrees } = useAppStore(); + const { useWorktrees, currentProject } = useAppStore(); const [isLifted, setIsLifted] = useState(false); useLayoutEffect(() => { @@ -213,6 +213,7 @@ export const KanbanCard = memo(function KanbanCard({ {/* Agent Info Panel */} (''); - const [isLoading, setIsLoading] = useState(true); + // Resolve project path - prefer prop, fallback to window.__currentProject + const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || ''; + + // Track additional content from WebSocket events (appended to query data) + const [streamedContent, setStreamedContent] = useState(''); const [viewMode, setViewMode] = useState(null); - const [projectPath, setProjectPath] = useState(''); + + // Use React Query for initial output loading + const { data: initialOutput = '', isLoading } = useAgentOutput( + resolvedProjectPath, + featureId, + open && !!resolvedProjectPath + ); + + // Reset streamed content when modal opens or featureId changes + useEffect(() => { + if (open) { + setStreamedContent(''); + } + }, [open, featureId]); + + // Combine initial output from query with streamed content from WebSocket + const output = initialOutput + streamedContent; // Extract summary from output const summary = useMemo(() => extractSummary(output), [output]); @@ -52,7 +72,6 @@ export function AgentOutputModal({ const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed'); const scrollRef = useRef(null); const autoScrollRef = useRef(true); - const projectPathRef = useRef(''); const useWorktrees = useAppStore((state) => state.useWorktrees); // Auto-scroll to bottom when output changes @@ -62,50 +81,6 @@ export function AgentOutputModal({ } }, [output]); - // Load existing output from file - useEffect(() => { - if (!open) return; - - const loadOutput = async () => { - const api = getElectronAPI(); - if (!api) return; - - setIsLoading(true); - - try { - // Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility - const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path; - if (!resolvedProjectPath) { - setIsLoading(false); - return; - } - - projectPathRef.current = resolvedProjectPath; - setProjectPath(resolvedProjectPath); - - // Use features API to get agent output - if (api.features) { - const result = await api.features.getAgentOutput(resolvedProjectPath, featureId); - - if (result.success) { - setOutput(result.content || ''); - } else { - setOutput(''); - } - } else { - setOutput(''); - } - } catch (error) { - console.error('Failed to load output:', error); - setOutput(''); - } finally { - setIsLoading(false); - } - }; - - loadOutput(); - }, [open, featureId, projectPathProp]); - // Listen to auto mode events and update output useEffect(() => { if (!open) return; @@ -264,8 +239,8 @@ export function AgentOutputModal({ } if (newContent) { - // Only update local state - server is the single source of truth for file writes - setOutput((prev) => prev + newContent); + // Append new content from WebSocket to streamed content + setStreamedContent((prev) => prev + newContent); } }); @@ -379,15 +354,15 @@ export function AgentOutputModal({ {/* Task Progress Panel - shows when tasks are being executed */} {effectiveViewMode === 'changes' ? (
- {projectPath ? ( + {resolvedProjectPath ? ( (null); const [browserUrl, setBrowserUrl] = useState(null); const [showBrowserFallback, setShowBrowserFallback] = useState(false); - // Branch fetching state - const [branches, setBranches] = useState([]); - const [isLoadingBranches, setIsLoadingBranches] = useState(false); // Track whether an operation completed that warrants a refresh const operationCompletedRef = useRef(false); + // Use React Query for branch fetching - only enabled when dialog is open + const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches( + open ? worktree?.path : undefined, + true // Include remote branches for PR base branch selection + ); + + // Filter out current worktree branch from the list + const branches = useMemo(() => { + if (!branchesData?.branches) return []; + return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch); + }, [branchesData?.branches, worktree?.branch]); + // Common state reset function to avoid duplication const resetState = useCallback(() => { setTitle(''); @@ -71,44 +81,13 @@ export function CreatePRDialog({ setBrowserUrl(null); setShowBrowserFallback(false); operationCompletedRef.current = false; - setBranches([]); }, [defaultBaseBranch]); - // Fetch branches for autocomplete - const fetchBranches = useCallback(async () => { - if (!worktree?.path) return; - - setIsLoadingBranches(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.listBranches) { - return; - } - // Fetch both local and remote branches for PR base branch selection - const result = await api.worktree.listBranches(worktree.path, true); - if (result.success && result.result) { - // Extract branch names, filtering out the current worktree branch - const branchNames = result.result.branches - .map((b) => b.name) - .filter((name) => name !== worktree.branch); - setBranches(branchNames); - } - } catch { - // Silently fail - branches will default to main only - } finally { - setIsLoadingBranches(false); - } - }, [worktree?.path, worktree?.branch]); - // Reset state when dialog opens or worktree changes useEffect(() => { // Reset all state on both open and close resetState(); - if (open) { - // Fetch fresh branches when dialog opens - fetchBranches(); - } - }, [open, worktree?.path, resetState, fetchBranches]); + }, [open, worktree?.path, resetState]); const handleCreate = async () => { if (!worktree) return; diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 78c0526f..58877c92 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -14,6 +14,7 @@ import { getElectronAPI } from '@/lib/electron'; import { isConnectionError, handleServerOffline } from '@/lib/http-api-client'; import { toast } from 'sonner'; import { useAutoMode } from '@/hooks/use-auto-mode'; +import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations'; import { truncateDescription } from '@/lib/utils'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { createLogger } from '@automaker/utils/logger'; @@ -94,6 +95,10 @@ export function useBoardActions({ } = useAppStore(); const autoMode = useAutoMode(); + // React Query mutations for feature operations + const verifyFeatureMutation = useVerifyFeature(currentProject?.path ?? ''); + const resumeFeatureMutation = useResumeFeature(currentProject?.path ?? ''); + // Worktrees are created when adding/editing features with a branch name // This ensures the worktree exists before the feature starts execution @@ -480,28 +485,9 @@ export function useBoardActions({ const handleVerifyFeature = useCallback( async (feature: Feature) => { if (!currentProject) return; - - try { - const api = getElectronAPI(); - if (!api?.autoMode) { - logger.error('Auto mode API not available'); - return; - } - - const result = await api.autoMode.verifyFeature(currentProject.path, feature.id); - - if (result.success) { - logger.info('Feature verification started successfully'); - } else { - logger.error('Failed to verify feature:', result.error); - await loadFeatures(); - } - } catch (error) { - logger.error('Error verifying feature:', error); - await loadFeatures(); - } + verifyFeatureMutation.mutate(feature.id); }, - [currentProject, loadFeatures] + [currentProject, verifyFeatureMutation] ); const handleResumeFeature = useCallback( @@ -511,40 +497,9 @@ export function useBoardActions({ logger.error('No current project'); return; } - - try { - const api = getElectronAPI(); - if (!api?.autoMode) { - logger.error('Auto mode API not available'); - return; - } - - logger.info('Calling resumeFeature API...', { - projectPath: currentProject.path, - featureId: feature.id, - useWorktrees, - }); - - const result = await api.autoMode.resumeFeature( - currentProject.path, - feature.id, - useWorktrees - ); - - logger.info('resumeFeature result:', result); - - if (result.success) { - logger.info('Feature resume started successfully'); - } else { - logger.error('Failed to resume feature:', result.error); - await loadFeatures(); - } - } catch (error) { - logger.error('Error resuming feature:', error); - await loadFeatures(); - } + resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees }); }, - [currentProject, loadFeatures, useWorktrees] + [currentProject, resumeFeatureMutation, useWorktrees] ); const handleManualVerify = useCallback( diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts index e457e02e..1f60c458 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts @@ -1,8 +1,18 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; -import { useAppStore, Feature } from '@/store/app-store'; +/** + * Board Features Hook + * + * React Query-based hook for managing features on the board view. + * Handles feature loading, categories, and auto-mode event notifications. + */ + +import { useState, useCallback, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; import { createLogger } from '@automaker/utils/logger'; +import { useFeatures } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; const logger = createLogger('BoardFeatures'); @@ -11,105 +21,15 @@ interface UseBoardFeaturesProps { } export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { - const { features, setFeatures } = useAppStore(); - const [isLoading, setIsLoading] = useState(true); + const queryClient = useQueryClient(); const [persistedCategories, setPersistedCategories] = useState([]); - // Track previous project path to detect project switches - const prevProjectPathRef = useRef(null); - const isInitialLoadRef = useRef(true); - const isSwitchingProjectRef = useRef(false); - - // Load features using features API - // IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop - const loadFeatures = useCallback(async () => { - if (!currentProject) return; - - const currentPath = currentProject.path; - const previousPath = prevProjectPathRef.current; - const isProjectSwitch = previousPath !== null && currentPath !== previousPath; - - // Get cached features from store (without adding to dependencies) - const cachedFeatures = useAppStore.getState().features; - - // If project switched, mark it but don't clear features yet - // We'll clear after successful API load to prevent data loss - if (isProjectSwitch) { - logger.info(`Project switch detected: ${previousPath} -> ${currentPath}`); - isSwitchingProjectRef.current = true; - isInitialLoadRef.current = true; - } - - // Update the ref to track current project - prevProjectPathRef.current = currentPath; - - // Only show loading spinner on initial load to prevent board flash during reloads - if (isInitialLoadRef.current) { - setIsLoading(true); - } - - try { - const api = getElectronAPI(); - if (!api.features) { - logger.error('Features API not available'); - // Keep cached features if API is unavailable - return; - } - - const result = await api.features.getAll(currentProject.path); - - if (result.success && result.features) { - const featuresWithIds = result.features.map((f: any, index: number) => ({ - ...f, - id: f.id || `feature-${index}-${Date.now()}`, - status: f.status || 'backlog', - startedAt: f.startedAt, // Preserve startedAt timestamp - // Ensure model and thinkingLevel are set for backward compatibility - model: f.model || 'opus', - thinkingLevel: f.thinkingLevel || 'none', - })); - // Successfully loaded features - now safe to set them - setFeatures(featuresWithIds); - - // Only clear categories on project switch AFTER successful load - if (isProjectSwitch) { - setPersistedCategories([]); - } - - // Check for interrupted features and resume them - // This handles server restarts where features were in pipeline steps - if (api.autoMode?.resumeInterrupted) { - try { - await api.autoMode.resumeInterrupted(currentProject.path); - logger.info('Checked for interrupted features'); - } catch (resumeError) { - logger.warn('Failed to check for interrupted features:', resumeError); - } - } - } else if (!result.success && result.error) { - logger.error('API returned error:', result.error); - // If it's a new project or the error indicates no features found, - // that's expected - start with empty array - if (isProjectSwitch) { - setFeatures([]); - setPersistedCategories([]); - } - // Otherwise keep cached features - } - } catch (error) { - logger.error('Failed to load features:', error); - // On error, keep existing cached features for the current project - // Only clear on project switch if we have no features from server - if (isProjectSwitch && cachedFeatures.length === 0) { - setFeatures([]); - setPersistedCategories([]); - } - } finally { - setIsLoading(false); - isInitialLoadRef.current = false; - isSwitchingProjectRef.current = false; - } - }, [currentProject, setFeatures]); + // Use React Query for features + const { + data: features = [], + isLoading, + refetch: loadFeatures, + } = useFeatures(currentProject?.path); // Load persisted categories from file const loadCategories = useCallback(async () => { @@ -125,12 +45,9 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { setPersistedCategories(parsed); } } else { - // File doesn't exist, ensure categories are cleared setPersistedCategories([]); } - } catch (error) { - logger.error('Failed to load categories:', error); - // If file doesn't exist, ensure categories are cleared + } catch { setPersistedCategories([]); } }, [currentProject]); @@ -142,22 +59,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { try { const api = getElectronAPI(); - - // Read existing categories let categories: string[] = [...persistedCategories]; - // Add new category if it doesn't exist if (!categories.includes(category)) { categories.push(category); - categories.sort(); // Keep sorted + categories.sort(); - // Write back to file await api.writeFile( `${currentProject.path}/.automaker/categories.json`, JSON.stringify(categories, null, 2) ); - // Update state setPersistedCategories(categories); } } catch (error) { @@ -167,29 +79,8 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { [currentProject, persistedCategories] ); - // Subscribe to spec regeneration complete events to refresh kanban board - useEffect(() => { - const api = getElectronAPI(); - if (!api.specRegeneration) return; - - const unsubscribe = api.specRegeneration.onEvent((event) => { - // Refresh the kanban board when spec regeneration completes for the current project - if ( - event.type === 'spec_regeneration_complete' && - currentProject && - event.projectPath === currentProject.path - ) { - logger.info('Spec regeneration complete, refreshing features'); - loadFeatures(); - } - }); - - return () => { - unsubscribe(); - }; - }, [currentProject, loadFeatures]); - - // Listen for auto mode feature completion and errors to reload features + // Subscribe to auto mode events for notifications (ding sound, toasts) + // Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root useEffect(() => { const api = getElectronAPI(); if (!api?.autoMode || !currentProject) return; @@ -198,42 +89,22 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { const projectId = currentProject.id; const unsubscribe = api.autoMode.onEvent((event) => { - // Use event's projectPath or projectId if available, otherwise use current project - // Board view only reacts to events for the currently selected project const eventProjectId = ('projectId' in event && event.projectId) || projectId; if (event.type === 'auto_mode_feature_complete') { - // Reload features when a feature is completed - logger.info('Feature completed, reloading features...'); - loadFeatures(); // Play ding sound when feature is done (unless muted) const { muteDoneSound } = useAppStore.getState(); if (!muteDoneSound) { const audio = new Audio('/sounds/ding.mp3'); audio.play().catch((err) => logger.warn('Could not play ding sound:', err)); } - } else if (event.type === 'plan_approval_required') { - // Reload features when plan is generated and requires approval - // This ensures the feature card shows the "Approve Plan" button - logger.info('Plan approval required, reloading features...'); - loadFeatures(); - } else if (event.type === 'pipeline_step_started') { - // Pipeline steps update the feature status to `pipeline_*` before the step runs. - // Reload so the card moves into the correct pipeline column immediately. - logger.info('Pipeline step started, reloading features...'); - loadFeatures(); } else if (event.type === 'auto_mode_error') { - // Reload features when an error occurs (feature moved to waiting_approval) - logger.info('Feature error, reloading features...', event.error); - - // Remove from running tasks so it moves to the correct column + // Remove from running tasks if (event.featureId) { removeRunningTask(eventProjectId, event.featureId); } - loadFeatures(); - - // Check for authentication errors and show a more helpful message + // Show error toast const isAuthError = event.errorType === 'authentication' || (event.error && @@ -255,22 +126,46 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { }); return unsubscribe; - }, [loadFeatures, currentProject]); + }, [currentProject]); + // Check for interrupted features on mount useEffect(() => { - loadFeatures(); - }, [loadFeatures]); + if (!currentProject) return; - // Load persisted categories on mount + const checkInterrupted = async () => { + const api = getElectronAPI(); + if (api.autoMode?.resumeInterrupted) { + try { + await api.autoMode.resumeInterrupted(currentProject.path); + logger.info('Checked for interrupted features'); + } catch (error) { + logger.warn('Failed to check for interrupted features:', error); + } + } + }; + + checkInterrupted(); + }, [currentProject]); + + // Load persisted categories on mount/project change useEffect(() => { loadCategories(); }, [loadCategories]); + // Clear categories when project changes + useEffect(() => { + setPersistedCategories([]); + }, [currentProject?.path]); + return { features, isLoading, persistedCategories, - loadFeatures, + loadFeatures: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject?.path ?? ''), + }); + }, loadCategories, saveCategory, }; From d1219a225c2ff9ae301a0878ed1111d8223bc142 Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:21:36 +0100 Subject: [PATCH 07/76] refactor(ui): migrate worktree panel to React Query - Migrate use-worktrees to useWorktrees query hook - Migrate use-branches to useWorktreeBranches query hook - Migrate use-available-editors to useAvailableEditors query hook - Migrate use-worktree-actions to use mutation hooks - Update worktree-panel component to use query data - Remove manual state management for loading/errors Co-Authored-By: Claude Opus 4.5 --- .../hooks/use-available-editors.ts | 73 +++----- .../worktree-panel/hooks/use-branches.ts | 85 ++++----- .../hooks/use-worktree-actions.ts | 166 ++++-------------- .../worktree-panel/hooks/use-worktrees.ts | 84 ++++----- .../worktree-panel/worktree-panel.tsx | 30 +--- 5 files changed, 141 insertions(+), 297 deletions(-) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts index a3db9750..1d184c73 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts @@ -1,65 +1,46 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { createLogger } from '@automaker/utils/logger'; +import { useMemo, useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; +import { useAvailableEditors as useAvailableEditorsQuery } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; import type { EditorInfo } from '@automaker/types'; -const logger = createLogger('AvailableEditors'); - // Re-export EditorInfo for convenience export type { EditorInfo }; +/** + * Hook for fetching and managing available editors + * + * Uses React Query for data fetching with caching. + * Provides a refresh function that clears server cache and re-detects editors. + */ export function useAvailableEditors() { - const [editors, setEditors] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - - const fetchAvailableEditors = useCallback(async () => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.getAvailableEditors) { - setIsLoading(false); - return; - } - const result = await api.worktree.getAvailableEditors(); - if (result.success && result.result?.editors) { - setEditors(result.result.editors); - } - } catch (error) { - logger.error('Failed to fetch available editors:', error); - } finally { - setIsLoading(false); - } - }, []); + const queryClient = useQueryClient(); + const { data: editors = [], isLoading } = useAvailableEditorsQuery(); /** - * Refresh editors by clearing the server cache and re-detecting + * Mutation to refresh editors by clearing the server cache and re-detecting * Use this when the user has installed/uninstalled editors */ - const refresh = useCallback(async () => { - setIsRefreshing(true); - try { + const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({ + mutationFn: async () => { const api = getElectronAPI(); - if (!api?.worktree?.refreshEditors) { - // Fallback to regular fetch if refresh not available - await fetchAvailableEditors(); - return; - } const result = await api.worktree.refreshEditors(); - if (result.success && result.result?.editors) { - setEditors(result.result.editors); - logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`); + if (!result.success) { + throw new Error(result.error || 'Failed to refresh editors'); } - } catch (error) { - logger.error('Failed to refresh editors:', error); - } finally { - setIsRefreshing(false); - } - }, [fetchAvailableEditors]); + return result.result?.editors ?? []; + }, + onSuccess: (newEditors) => { + // Update the cache with new editors + queryClient.setQueryData(queryKeys.worktrees.editors(), newEditors); + }, + }); - useEffect(() => { - fetchAvailableEditors(); - }, [fetchAvailableEditors]); + const refresh = useCallback(() => { + refreshMutate(); + }, [refreshMutate]); return { editors, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts index 1cb1cec6..eeca9729 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts @@ -1,66 +1,43 @@ import { useState, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { getElectronAPI } from '@/lib/electron'; -import type { BranchInfo, GitRepoStatus } from '../types'; - -const logger = createLogger('Branches'); +import { useWorktreeBranches } from '@/hooks/queries'; +import type { GitRepoStatus } from '../types'; +/** + * Hook for managing branch data with React Query + * + * Uses useWorktreeBranches for data fetching while maintaining + * the current interface for backward compatibility. Tracks which + * worktree path is currently being viewed and fetches branches on demand. + */ export function useBranches() { - const [branches, setBranches] = useState([]); - const [aheadCount, setAheadCount] = useState(0); - const [behindCount, setBehindCount] = useState(0); - const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [currentWorktreePath, setCurrentWorktreePath] = useState(); const [branchFilter, setBranchFilter] = useState(''); - const [gitRepoStatus, setGitRepoStatus] = useState({ - isGitRepo: true, - hasCommits: true, - }); - /** Helper to reset branch state to initial values */ - const resetBranchState = useCallback(() => { - setBranches([]); - setAheadCount(0); - setBehindCount(0); - }, []); + const { + data: branchData, + isLoading: isLoadingBranches, + refetch, + } = useWorktreeBranches(currentWorktreePath); + + const branches = branchData?.branches ?? []; + const aheadCount = branchData?.aheadCount ?? 0; + const behindCount = branchData?.behindCount ?? 0; + const gitRepoStatus: GitRepoStatus = { + isGitRepo: branchData?.isGitRepo ?? true, + hasCommits: branchData?.hasCommits ?? true, + }; const fetchBranches = useCallback( - async (worktreePath: string) => { - setIsLoadingBranches(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.listBranches) { - logger.warn('List branches API not available'); - return; - } - const result = await api.worktree.listBranches(worktreePath); - if (result.success && result.result) { - setBranches(result.result.branches); - setAheadCount(result.result.aheadCount || 0); - setBehindCount(result.result.behindCount || 0); - setGitRepoStatus({ isGitRepo: true, hasCommits: true }); - } else if (result.code === 'NOT_GIT_REPO') { - // Not a git repository - clear branches silently without logging an error - resetBranchState(); - setGitRepoStatus({ isGitRepo: false, hasCommits: false }); - } else if (result.code === 'NO_COMMITS') { - // Git repo but no commits yet - clear branches silently without logging an error - resetBranchState(); - setGitRepoStatus({ isGitRepo: true, hasCommits: false }); - } else if (!result.success) { - // Other errors - log them - logger.warn('Failed to fetch branches:', result.error); - resetBranchState(); - } - } catch (error) { - logger.error('Failed to fetch branches:', error); - resetBranchState(); - // Reset git status to unknown state on network/API errors - setGitRepoStatus({ isGitRepo: true, hasCommits: true }); - } finally { - setIsLoadingBranches(false); + (worktreePath: string) => { + if (worktreePath === currentWorktreePath) { + // Same path - just refetch to get latest data + refetch(); + } else { + // Different path - update the tracked path (triggers new query) + setCurrentWorktreePath(worktreePath); } }, - [resetBranchState] + [currentWorktreePath, refetch] ); const resetBranchFilter = useCallback(() => { diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index f1f245dc..50eddc58 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -1,152 +1,64 @@ import { useState, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { getElectronAPI } from '@/lib/electron'; -import { toast } from 'sonner'; +import { + useSwitchBranch, + usePullWorktree, + usePushWorktree, + useOpenInEditor, +} from '@/hooks/mutations'; import type { WorktreeInfo } from '../types'; -const logger = createLogger('WorktreeActions'); - -// Error codes that need special user-friendly handling -const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const; -type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number]; - -// User-friendly messages for git status errors -const GIT_STATUS_ERROR_MESSAGES: Record = { - NOT_GIT_REPO: 'This directory is not a git repository', - NO_COMMITS: 'Repository has no commits yet. Create an initial commit first.', -}; - -/** - * Helper to handle git status errors with user-friendly messages. - * @returns true if the error was a git status error and was handled, false otherwise. - */ -function handleGitStatusError(result: { code?: string; error?: string }): boolean { - const errorCode = result.code as GitStatusErrorCode | undefined; - if (errorCode && GIT_STATUS_ERROR_CODES.includes(errorCode)) { - toast.info(GIT_STATUS_ERROR_MESSAGES[errorCode] || result.error); - return true; - } - return false; -} - -interface UseWorktreeActionsOptions { - fetchWorktrees: () => Promise | undefined>; - fetchBranches: (worktreePath: string) => Promise; -} - -export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) { - const [isPulling, setIsPulling] = useState(false); - const [isPushing, setIsPushing] = useState(false); - const [isSwitching, setIsSwitching] = useState(false); +export function useWorktreeActions() { const [isActivating, setIsActivating] = useState(false); + // Use React Query mutations + const switchBranchMutation = useSwitchBranch(); + const pullMutation = usePullWorktree(); + const pushMutation = usePushWorktree(); + const openInEditorMutation = useOpenInEditor(); + const handleSwitchBranch = useCallback( async (worktree: WorktreeInfo, branchName: string) => { - if (isSwitching || branchName === worktree.branch) return; - setIsSwitching(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.switchBranch) { - toast.error('Switch branch API not available'); - return; - } - const result = await api.worktree.switchBranch(worktree.path, branchName); - if (result.success && result.result) { - toast.success(result.result.message); - fetchWorktrees(); - } else { - if (handleGitStatusError(result)) return; - toast.error(result.error || 'Failed to switch branch'); - } - } catch (error) { - logger.error('Switch branch failed:', error); - toast.error('Failed to switch branch'); - } finally { - setIsSwitching(false); - } + if (switchBranchMutation.isPending || branchName === worktree.branch) return; + switchBranchMutation.mutate({ + worktreePath: worktree.path, + branchName, + }); }, - [isSwitching, fetchWorktrees] + [switchBranchMutation] ); const handlePull = useCallback( async (worktree: WorktreeInfo) => { - if (isPulling) return; - setIsPulling(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.pull) { - toast.error('Pull API not available'); - return; - } - const result = await api.worktree.pull(worktree.path); - if (result.success && result.result) { - toast.success(result.result.message); - fetchWorktrees(); - } else { - if (handleGitStatusError(result)) return; - toast.error(result.error || 'Failed to pull latest changes'); - } - } catch (error) { - logger.error('Pull failed:', error); - toast.error('Failed to pull latest changes'); - } finally { - setIsPulling(false); - } + if (pullMutation.isPending) return; + pullMutation.mutate(worktree.path); }, - [isPulling, fetchWorktrees] + [pullMutation] ); const handlePush = useCallback( async (worktree: WorktreeInfo) => { - if (isPushing) return; - setIsPushing(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.push) { - toast.error('Push API not available'); - return; - } - const result = await api.worktree.push(worktree.path); - if (result.success && result.result) { - toast.success(result.result.message); - fetchBranches(worktree.path); - fetchWorktrees(); - } else { - if (handleGitStatusError(result)) return; - toast.error(result.error || 'Failed to push changes'); - } - } catch (error) { - logger.error('Push failed:', error); - toast.error('Failed to push changes'); - } finally { - setIsPushing(false); - } + if (pushMutation.isPending) return; + pushMutation.mutate({ + worktreePath: worktree.path, + }); }, - [isPushing, fetchBranches, fetchWorktrees] + [pushMutation] ); - const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.openInEditor) { - logger.warn('Open in editor API not available'); - return; - } - const result = await api.worktree.openInEditor(worktree.path, editorCommand); - if (result.success && result.result) { - toast.success(result.result.message); - } else if (result.error) { - toast.error(result.error); - } - } catch (error) { - logger.error('Open in editor failed:', error); - } - }, []); + const handleOpenInEditor = useCallback( + async (worktree: WorktreeInfo, editorCommand?: string) => { + openInEditorMutation.mutate({ + worktreePath: worktree.path, + editorCommand, + }); + }, + [openInEditorMutation] + ); return { - isPulling, - isPushing, - isSwitching, + isPulling: pullMutation.isPending, + isPushing: pushMutation.isPending, + isSwitching: switchBranchMutation.isPending, isActivating, setIsActivating, handleSwitchBranch, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts index 1575f38a..6a3276ec 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts @@ -1,12 +1,11 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { createLogger } from '@automaker/utils/logger'; +import { useEffect, useCallback, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useAppStore } from '@/store/app-store'; -import { getElectronAPI } from '@/lib/electron'; +import { useWorktrees as useWorktreesQuery } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; import { pathsEqual } from '@/lib/utils'; import type { WorktreeInfo } from '../types'; -const logger = createLogger('Worktrees'); - interface UseWorktreesOptions { projectPath: string; refreshTrigger?: number; @@ -18,59 +17,46 @@ export function useWorktrees({ refreshTrigger = 0, onRemovedWorktrees, }: UseWorktreesOptions) { - const [isLoading, setIsLoading] = useState(false); - const [worktrees, setWorktrees] = useState([]); + const queryClient = useQueryClient(); const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath)); const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree); const setWorktreesInStore = useAppStore((s) => s.setWorktrees); const useWorktreesEnabled = useAppStore((s) => s.useWorktrees); - const fetchWorktrees = useCallback( - async (options?: { silent?: boolean }) => { - if (!projectPath) return; - const silent = options?.silent ?? false; - if (!silent) { - setIsLoading(true); - } - try { - const api = getElectronAPI(); - if (!api?.worktree?.listAll) { - logger.warn('Worktree API not available'); - return; - } - const result = await api.worktree.listAll(projectPath, true); - if (result.success && result.worktrees) { - setWorktrees(result.worktrees); - setWorktreesInStore(projectPath, result.worktrees); - } - // Return removed worktrees so they can be handled by the caller - return result.removedWorktrees; - } catch (error) { - logger.error('Failed to fetch worktrees:', error); - return undefined; - } finally { - if (!silent) { - setIsLoading(false); - } - } - }, - [projectPath, setWorktreesInStore] - ); + // Use the React Query hook + const { data, isLoading, refetch } = useWorktreesQuery(projectPath); + const worktrees = (data?.worktrees ?? []) as WorktreeInfo[]; + // Sync worktrees to Zustand store when they change useEffect(() => { - fetchWorktrees(); - }, [fetchWorktrees]); + if (worktrees.length > 0) { + setWorktreesInStore(projectPath, worktrees); + } + }, [worktrees, projectPath, setWorktreesInStore]); + // Handle removed worktrees callback when data changes + const prevRemovedWorktreesRef = useRef(null); + useEffect(() => { + if (data?.removedWorktrees && data.removedWorktrees.length > 0) { + // Create a stable key to avoid duplicate callbacks + const key = JSON.stringify(data.removedWorktrees); + if (key !== prevRemovedWorktreesRef.current) { + prevRemovedWorktreesRef.current = key; + onRemovedWorktrees?.(data.removedWorktrees); + } + } + }, [data?.removedWorktrees, onRemovedWorktrees]); + + // Handle refresh trigger useEffect(() => { if (refreshTrigger > 0) { - fetchWorktrees().then((removedWorktrees) => { - if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) { - onRemovedWorktrees(removedWorktrees); - } + // Invalidate and refetch to get fresh data including any removed worktrees + queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.all(projectPath), }); } - }, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]); + }, [refreshTrigger, projectPath, queryClient]); // Use a ref to track the current worktree to avoid running validation // when selection changes (which could cause a race condition with stale worktrees list) @@ -108,6 +94,14 @@ export function useWorktrees({ [projectPath, setCurrentWorktree] ); + // fetchWorktrees for backward compatibility - now just triggers a refetch + const fetchWorktrees = useCallback(async () => { + await queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.all(projectPath), + }); + return refetch(); + }, [projectPath, queryClient, refetch]); + const currentWorktreePath = currentWorktree?.path ?? null; const selectedWorktree = currentWorktreePath ? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath)) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 2cc844f4..8550ce7d 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -5,6 +5,7 @@ import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; import { getHttpApiClient } from '@/lib/http-api-client'; import { useIsMobile } from '@/hooks/use-media-query'; +import { useWorktreeInitScript } from '@/hooks/queries'; import type { WorktreePanelProps, WorktreeInfo } from './types'; import { useWorktrees, @@ -79,42 +80,21 @@ export function WorktreePanel({ handlePull, handlePush, handleOpenInEditor, - } = useWorktreeActions({ - fetchWorktrees, - fetchBranches, - }); + } = useWorktreeActions(); const { hasRunningFeatures } = useRunningFeatures({ runningFeatureIds, features, }); - // Track whether init script exists for the project - const [hasInitScript, setHasInitScript] = useState(false); + // Check if init script exists for the project using React Query + const { data: initScriptData } = useWorktreeInitScript(projectPath); + const hasInitScript = initScriptData?.exists ?? false; // Log panel state management const [logPanelOpen, setLogPanelOpen] = useState(false); const [logPanelWorktree, setLogPanelWorktree] = useState(null); - useEffect(() => { - if (!projectPath) { - setHasInitScript(false); - return; - } - - const checkInitScript = async () => { - try { - const api = getHttpApiClient(); - const result = await api.worktree.getInitScript(projectPath); - setHasInitScript(result.success && result.exists); - } catch { - setHasInitScript(false); - } - }; - - checkInitScript(); - }, [projectPath]); - const isMobile = useIsMobile(); // Periodic interval check (5 seconds) to detect branch changes on disk From c4e0a7cc96932a9056df6996973126b3c7de975f Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:21:49 +0100 Subject: [PATCH 08/76] refactor(ui): migrate GitHub views to React Query - Migrate use-github-issues to useGitHubIssues query - Migrate use-issue-comments to useGitHubIssueComments infinite query - Migrate use-issue-validation to useGitHubValidations with mutations - Migrate github-prs-view to useGitHubPRs query - Support pagination for comments with useInfiniteQuery - Remove manual loading state management Co-Authored-By: Claude Opus 4.5 --- .../hooks/use-github-issues.ts | 82 +++-------- .../hooks/use-issue-comments.ts | 127 +++--------------- .../hooks/use-issue-validation.ts | 64 +++------ .../src/components/views/github-prs-view.tsx | 81 +++++------ 4 files changed, 89 insertions(+), 265 deletions(-) diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts index 0083a877..a97667f1 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts @@ -1,79 +1,29 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { getElectronAPI, GitHubIssue } from '@/lib/electron'; +/** + * GitHub Issues Hook + * + * React Query-based hook for fetching GitHub issues. + */ -const logger = createLogger('GitHubIssues'); import { useAppStore } from '@/store/app-store'; +import { useGitHubIssues as useGitHubIssuesQuery } from '@/hooks/queries'; export function useGithubIssues() { const { currentProject } = useAppStore(); - const [openIssues, setOpenIssues] = useState([]); - const [closedIssues, setClosedIssues] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const isMountedRef = useRef(true); - const fetchIssues = useCallback(async () => { - if (!currentProject?.path) { - if (isMountedRef.current) { - setError('No project selected'); - setLoading(false); - } - return; - } - - try { - if (isMountedRef.current) { - setError(null); - } - const api = getElectronAPI(); - if (api.github) { - const result = await api.github.listIssues(currentProject.path); - if (isMountedRef.current) { - if (result.success) { - setOpenIssues(result.openIssues || []); - setClosedIssues(result.closedIssues || []); - } else { - setError(result.error || 'Failed to fetch issues'); - } - } - } - } catch (err) { - if (isMountedRef.current) { - logger.error('Error fetching issues:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch issues'); - } - } finally { - if (isMountedRef.current) { - setLoading(false); - setRefreshing(false); - } - } - }, [currentProject?.path]); - - useEffect(() => { - isMountedRef.current = true; - fetchIssues(); - - return () => { - isMountedRef.current = false; - }; - }, [fetchIssues]); - - const refresh = useCallback(() => { - if (isMountedRef.current) { - setRefreshing(true); - } - fetchIssues(); - }, [fetchIssues]); + const { + data, + isLoading: loading, + isFetching: refreshing, + error, + refetch: refresh, + } = useGitHubIssuesQuery(currentProject?.path); return { - openIssues, - closedIssues, + openIssues: data?.openIssues ?? [], + closedIssues: data?.closedIssues ?? [], loading, refreshing, - error, + error: error instanceof Error ? error.message : error ? String(error) : null, refresh, }; } diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts index 7ae1b130..44f36ac8 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts @@ -1,9 +1,7 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { getElectronAPI, GitHubComment } from '@/lib/electron'; - -const logger = createLogger('IssueComments'); +import { useMemo, useCallback } from 'react'; +import type { GitHubComment } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; +import { useGitHubIssueComments } from '@/hooks/queries'; interface UseIssueCommentsResult { comments: GitHubComment[]; @@ -18,119 +16,36 @@ interface UseIssueCommentsResult { export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult { const { currentProject } = useAppStore(); - const [comments, setComments] = useState([]); - const [totalCount, setTotalCount] = useState(0); - const [loading, setLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [hasNextPage, setHasNextPage] = useState(false); - const [endCursor, setEndCursor] = useState(undefined); - const [error, setError] = useState(null); - const isMountedRef = useRef(true); - const fetchComments = useCallback( - async (cursor?: string) => { - if (!currentProject?.path || !issueNumber) { - return; - } + // Use React Query infinite query + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch, error } = + useGitHubIssueComments(currentProject?.path, issueNumber ?? undefined); - const isLoadingMore = !!cursor; + // Flatten all pages into a single comments array + const comments = useMemo(() => { + return data?.pages.flatMap((page) => page.comments) ?? []; + }, [data?.pages]); - try { - if (isMountedRef.current) { - setError(null); - if (isLoadingMore) { - setLoadingMore(true); - } else { - setLoading(true); - } - } - - const api = getElectronAPI(); - if (api.github) { - const result = await api.github.getIssueComments( - currentProject.path, - issueNumber, - cursor - ); - - if (isMountedRef.current) { - if (result.success) { - if (isLoadingMore) { - // Append new comments - setComments((prev) => [...prev, ...(result.comments || [])]); - } else { - // Replace all comments - setComments(result.comments || []); - } - setTotalCount(result.totalCount || 0); - setHasNextPage(result.hasNextPage || false); - setEndCursor(result.endCursor); - } else { - setError(result.error || 'Failed to fetch comments'); - } - } - } - } catch (err) { - if (isMountedRef.current) { - logger.error('Error fetching comments:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch comments'); - } - } finally { - if (isMountedRef.current) { - setLoading(false); - setLoadingMore(false); - } - } - }, - [currentProject?.path, issueNumber] - ); - - // Reset and fetch when issue changes - useEffect(() => { - isMountedRef.current = true; - - if (issueNumber) { - // Reset state when issue changes - setComments([]); - setTotalCount(0); - setHasNextPage(false); - setEndCursor(undefined); - setError(null); - fetchComments(); - } else { - // Clear comments when no issue is selected - setComments([]); - setTotalCount(0); - setHasNextPage(false); - setEndCursor(undefined); - setLoading(false); - setError(null); - } - - return () => { - isMountedRef.current = false; - }; - }, [issueNumber, fetchComments]); + // Get total count from the first page + const totalCount = data?.pages[0]?.totalCount ?? 0; const loadMore = useCallback(() => { - if (hasNextPage && endCursor && !loadingMore) { - fetchComments(endCursor); + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); } - }, [hasNextPage, endCursor, loadingMore, fetchComments]); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); const refresh = useCallback(() => { - setComments([]); - setEndCursor(undefined); - fetchComments(); - }, [fetchComments]); + refetch(); + }, [refetch]); return { comments, totalCount, - loading, - loadingMore, - hasNextPage, - error, + loading: isLoading, + loadingMore: isFetchingNextPage, + hasNextPage: hasNextPage ?? false, + error: error instanceof Error ? error.message : null, loadMore, refresh, }; diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts index c09baab0..788a9efe 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts @@ -13,6 +13,7 @@ import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { toast } from 'sonner'; import { isValidationStale } from '../utils'; +import { useValidateIssue, useMarkValidationViewed } from '@/hooks/mutations'; const logger = createLogger('IssueValidation'); @@ -46,6 +47,10 @@ export function useIssueValidation({ new Map() ); const audioRef = useRef(null); + + // React Query mutations + const validateIssueMutation = useValidateIssue(currentProject?.path ?? ''); + const markViewedMutation = useMarkValidationViewed(currentProject?.path ?? ''); // Refs for stable event handler (avoids re-subscribing on state changes) const selectedIssueRef = useRef(null); const showValidationDialogRef = useRef(false); @@ -240,7 +245,7 @@ export function useIssueValidation({ } // Check if already validating this issue - if (validatingIssues.has(issue.number)) { + if (validatingIssues.has(issue.number) || validateIssueMutation.isPending) { toast.info(`Validation already in progress for issue #${issue.number}`); return; } @@ -254,11 +259,6 @@ export function useIssueValidation({ return; } - // Start async validation in background (no dialog - user will see badge when done) - toast.info(`Starting validation for issue #${issue.number}`, { - description: 'You will be notified when the analysis is complete', - }); - // Use provided model override or fall back to phaseModels.validationModel // Extract model string and thinking level from PhaseModelEntry (handles both old string format and new object format) const effectiveModelEntry = modelEntry @@ -276,40 +276,22 @@ export function useIssueValidation({ const thinkingLevelToUse = normalizedEntry.thinkingLevel; const reasoningEffortToUse = normalizedEntry.reasoningEffort; - try { - const api = getElectronAPI(); - if (api.github?.validateIssue) { - const validationInput = { - issueNumber: issue.number, - issueTitle: issue.title, - issueBody: issue.body || '', - issueLabels: issue.labels.map((l) => l.name), - comments, // Include comments if provided - linkedPRs, // Include linked PRs if provided - }; - const result = await api.github.validateIssue( - currentProject.path, - validationInput, - modelToUse, - thinkingLevelToUse, - reasoningEffortToUse - ); - - if (!result.success) { - toast.error(result.error || 'Failed to start validation'); - } - // On success, the result will come through the event stream - } - } catch (err) { - logger.error('Validation error:', err); - toast.error(err instanceof Error ? err.message : 'Failed to validate issue'); - } + // Use mutation to trigger validation (toast is handled by mutation) + validateIssueMutation.mutate({ + issue, + model: modelToUse, + thinkingLevel: thinkingLevelToUse, + reasoningEffort: reasoningEffortToUse, + comments, + linkedPRs, + }); }, [ currentProject?.path, validatingIssues, cachedValidations, phaseModels.validationModel, + validateIssueMutation, onValidationResultChange, onShowValidationDialogChange, ] @@ -325,10 +307,8 @@ export function useIssueValidation({ // Mark as viewed if not already viewed if (!cached.viewedAt && currentProject?.path) { - try { - const api = getElectronAPI(); - if (api.github?.markValidationViewed) { - await api.github.markValidationViewed(currentProject.path, issue.number); + markViewedMutation.mutate(issue.number, { + onSuccess: () => { // Update local state setCachedValidations((prev) => { const next = new Map(prev); @@ -341,16 +321,15 @@ export function useIssueValidation({ } return next; }); - } - } catch (err) { - logger.error('Failed to mark validation as viewed:', err); - } + }, + }); } } }, [ cachedValidations, currentProject?.path, + markViewedMutation, onValidationResultChange, onShowValidationDialogChange, ] @@ -361,5 +340,6 @@ export function useIssueValidation({ cachedValidations, handleValidateIssue, handleViewCachedValidation, + isValidating: validateIssueMutation.isPending, }; } diff --git a/apps/ui/src/components/views/github-prs-view.tsx b/apps/ui/src/components/views/github-prs-view.tsx index 855d136c..9abfe5b1 100644 --- a/apps/ui/src/components/views/github-prs-view.tsx +++ b/apps/ui/src/components/views/github-prs-view.tsx @@ -1,59 +1,36 @@ -import { useState, useEffect, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; +/** + * GitHub PRs View + * + * Displays pull requests using React Query for data fetching. + */ + +import { useState, useCallback } from 'react'; import { GitPullRequest, Loader2, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react'; -import { getElectronAPI, GitHubPR } from '@/lib/electron'; +import { getElectronAPI, type GitHubPR } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { Markdown } from '@/components/ui/markdown'; import { cn } from '@/lib/utils'; - -const logger = createLogger('GitHubPRsView'); +import { useGitHubPRs } from '@/hooks/queries'; export function GitHubPRsView() { - const [openPRs, setOpenPRs] = useState([]); - const [mergedPRs, setMergedPRs] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); const [selectedPR, setSelectedPR] = useState(null); const { currentProject } = useAppStore(); - const fetchPRs = useCallback(async () => { - if (!currentProject?.path) { - setError('No project selected'); - setLoading(false); - return; - } + const { + data, + isLoading: loading, + isFetching: refreshing, + error, + refetch, + } = useGitHubPRs(currentProject?.path); - try { - setError(null); - const api = getElectronAPI(); - if (api.github) { - const result = await api.github.listPRs(currentProject.path); - if (result.success) { - setOpenPRs(result.openPRs || []); - setMergedPRs(result.mergedPRs || []); - } else { - setError(result.error || 'Failed to fetch pull requests'); - } - } - } catch (err) { - logger.error('Error fetching PRs:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch pull requests'); - } finally { - setLoading(false); - setRefreshing(false); - } - }, [currentProject?.path]); - - useEffect(() => { - fetchPRs(); - }, [fetchPRs]); + const openPRs = data?.openPRs ?? []; + const mergedPRs = data?.mergedPRs ?? []; const handleRefresh = useCallback(() => { - setRefreshing(true); - fetchPRs(); - }, [fetchPRs]); + refetch(); + }, [refetch]); const handleOpenInGitHub = useCallback((url: string) => { const api = getElectronAPI(); @@ -98,7 +75,9 @@ export function GitHubPRsView() {

Failed to Load Pull Requests

-

{error}

+

+ {error instanceof Error ? error.message : 'Failed to fetch pull requests'} +

{CLAUDE_USAGE_SUBTITLE}

@@ -194,10 +143,10 @@ export function ClaudeUsageSection() {
)} - {error && !showAuthWarning && ( + {errorMessage && !showAuthWarning && (
-
{error}
+
{errorMessage}
)} @@ -219,7 +168,7 @@ export function ClaudeUsageSection() {
)} - {!hasUsage && !error && !showAuthWarning && !isLoading && ( + {!hasUsage && !errorMessage && !showAuthWarning && !isLoading && (
{CLAUDE_NO_USAGE_MESSAGE}
diff --git a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx index b879df4a..5d68e230 100644 --- a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx @@ -1,19 +1,16 @@ -// @ts-nocheck -import { useCallback, useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { RefreshCw, AlertCircle } from 'lucide-react'; import { OpenAIIcon } from '@/components/ui/provider-icon'; import { cn } from '@/lib/utils'; -import { getElectronAPI } from '@/lib/electron'; import { formatCodexPlanType, formatCodexResetTime, getCodexWindowLabel, } from '@/lib/codex-usage-format'; import { useSetupStore } from '@/store/setup-store'; -import { useAppStore, type CodexRateLimitWindow } from '@/store/app-store'; +import { useCodexUsage } from '@/hooks/queries'; +import type { CodexRateLimitWindow } from '@/store/app-store'; -const ERROR_NO_API = 'Codex usage API not available'; const CODEX_USAGE_TITLE = 'Codex Usage'; const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.'; const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.'; @@ -21,14 +18,11 @@ const CODEX_LOGIN_COMMAND = 'codex login'; const CODEX_NO_USAGE_MESSAGE = 'Usage limits are not available yet. Try refreshing if this persists.'; const UPDATED_LABEL = 'Updated'; -const CODEX_FETCH_ERROR = 'Failed to fetch usage'; const CODEX_REFRESH_LABEL = 'Refresh Codex usage'; const PLAN_LABEL = 'Plan'; const WARNING_THRESHOLD = 75; const CAUTION_THRESHOLD = 50; const MAX_PERCENTAGE = 100; -const REFRESH_INTERVAL_MS = 60_000; -const STALE_THRESHOLD_MS = 2 * 60_000; const USAGE_COLOR_CRITICAL = 'bg-red-500'; const USAGE_COLOR_WARNING = 'bg-amber-500'; const USAGE_COLOR_OK = 'bg-emerald-500'; @@ -39,11 +33,12 @@ const isRateLimitWindow = ( export function CodexUsageSection() { const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); - const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); const canFetchUsage = !!codexAuthStatus?.authenticated; + + // Use React Query for data fetching with automatic polling + const { data: codexUsage, isLoading, isFetching, error, refetch } = useCodexUsage(canFetchUsage); + const rateLimits = codexUsage?.rateLimits ?? null; const primary = rateLimits?.primary ?? null; const secondary = rateLimits?.secondary ?? null; @@ -54,46 +49,7 @@ export function CodexUsageSection() { ? new Date(codexUsage.lastUpdated).toLocaleString() : null; const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading; - const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS; - - const fetchUsage = useCallback(async () => { - setIsLoading(true); - setError(null); - try { - const api = getElectronAPI(); - if (!api.codex) { - setError(ERROR_NO_API); - return; - } - const result = await api.codex.getUsage(); - if ('error' in result) { - setError(result.message || result.error); - return; - } - setCodexUsage(result); - } catch (fetchError) { - const message = fetchError instanceof Error ? fetchError.message : CODEX_FETCH_ERROR; - setError(message); - } finally { - setIsLoading(false); - } - }, [setCodexUsage]); - - useEffect(() => { - if (canFetchUsage && isStale) { - void fetchUsage(); - } - }, [fetchUsage, canFetchUsage, isStale]); - - useEffect(() => { - if (!canFetchUsage) return undefined; - - const intervalId = setInterval(() => { - void fetchUsage(); - }, REFRESH_INTERVAL_MS); - - return () => clearInterval(intervalId); - }, [fetchUsage, canFetchUsage]); + const errorMessage = error instanceof Error ? error.message : error ? String(error) : null; const getUsageColor = (percentage: number) => { if (percentage >= WARNING_THRESHOLD) { @@ -162,13 +118,13 @@ export function CodexUsageSection() {

{CODEX_USAGE_SUBTITLE}

@@ -182,10 +138,10 @@ export function CodexUsageSection() {
)} - {error && ( + {errorMessage && (
-
{error}
+
{errorMessage}
)} {hasMetrics && ( @@ -210,7 +166,7 @@ export function CodexUsageSection() {
)} - {!hasMetrics && !error && canFetchUsage && !isLoading && ( + {!hasMetrics && !errorMessage && canFetchUsage && !isLoading && (
{CODEX_NO_USAGE_MESSAGE}
diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts b/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts index a911892e..a7327686 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts @@ -1,103 +1,52 @@ -import { useState, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { toast } from 'sonner'; +import { useState, useCallback, useEffect } from 'react'; +import { useCursorPermissionsQuery, type CursorPermissionsData } from '@/hooks/queries'; +import { useApplyCursorProfile, useCopyCursorConfig } from '@/hooks/mutations'; -const logger = createLogger('CursorPermissions'); -import { getHttpApiClient } from '@/lib/http-api-client'; -import type { CursorPermissionProfile } from '@automaker/types'; - -export interface PermissionsData { - activeProfile: CursorPermissionProfile | null; - effectivePermissions: { allow: string[]; deny: string[] } | null; - hasProjectConfig: boolean; - availableProfiles: Array<{ - id: string; - name: string; - description: string; - permissions: { allow: string[]; deny: string[] }; - }>; -} +// Re-export for backward compatibility +export type PermissionsData = CursorPermissionsData; /** * Custom hook for managing Cursor CLI permissions * Handles loading permissions data, applying profiles, and copying configs */ export function useCursorPermissions(projectPath?: string) { - const [permissions, setPermissions] = useState(null); - const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); - const [isSavingPermissions, setIsSavingPermissions] = useState(false); const [copiedConfig, setCopiedConfig] = useState(false); - // Load permissions data - const loadPermissions = useCallback(async () => { - setIsLoadingPermissions(true); - try { - const api = getHttpApiClient(); - const result = await api.setup.getCursorPermissions(projectPath); - - if (result.success) { - setPermissions({ - activeProfile: result.activeProfile || null, - effectivePermissions: result.effectivePermissions || null, - hasProjectConfig: result.hasProjectConfig || false, - availableProfiles: result.availableProfiles || [], - }); - } - } catch (error) { - logger.error('Failed to load Cursor permissions:', error); - } finally { - setIsLoadingPermissions(false); - } - }, [projectPath]); + // React Query hooks + const permissionsQuery = useCursorPermissionsQuery(projectPath); + const applyProfileMutation = useApplyCursorProfile(projectPath); + const copyConfigMutation = useCopyCursorConfig(); // Apply a permission profile const applyProfile = useCallback( - async (profileId: 'strict' | 'development', scope: 'global' | 'project') => { - setIsSavingPermissions(true); - try { - const api = getHttpApiClient(); - const result = await api.setup.applyCursorPermissionProfile( - profileId, - scope, - scope === 'project' ? projectPath : undefined - ); - - if (result.success) { - toast.success(result.message || `Applied ${profileId} profile`); - await loadPermissions(); - } else { - toast.error(result.error || 'Failed to apply profile'); - } - } catch (error) { - toast.error('Failed to apply profile'); - } finally { - setIsSavingPermissions(false); - } + (profileId: 'strict' | 'development', scope: 'global' | 'project') => { + applyProfileMutation.mutate({ profileId, scope }); }, - [projectPath, loadPermissions] + [applyProfileMutation] ); // Copy example config to clipboard - const copyConfig = useCallback(async (profileId: 'strict' | 'development') => { - try { - const api = getHttpApiClient(); - const result = await api.setup.getCursorExampleConfig(profileId); + const copyConfig = useCallback( + (profileId: 'strict' | 'development') => { + copyConfigMutation.mutate(profileId, { + onSuccess: () => { + setCopiedConfig(true); + setTimeout(() => setCopiedConfig(false), 2000); + }, + }); + }, + [copyConfigMutation] + ); - if (result.success && result.config) { - await navigator.clipboard.writeText(result.config); - setCopiedConfig(true); - toast.success('Config copied to clipboard'); - setTimeout(() => setCopiedConfig(false), 2000); - } - } catch (error) { - toast.error('Failed to copy config'); - } - }, []); + // Load permissions (refetch) + const loadPermissions = useCallback(() => { + permissionsQuery.refetch(); + }, [permissionsQuery]); return { - permissions, - isLoadingPermissions, - isSavingPermissions, + permissions: permissionsQuery.data ?? null, + isLoadingPermissions: permissionsQuery.isLoading, + isSavingPermissions: applyProfileMutation.isPending, copiedConfig, loadPermissions, applyProfile, diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts b/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts index a082e71b..6a39f7ca 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts @@ -1,9 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { toast } from 'sonner'; - -const logger = createLogger('CursorStatus'); -import { getHttpApiClient } from '@/lib/http-api-client'; +import { useEffect, useMemo, useCallback } from 'react'; +import { useCursorCliStatus } from '@/hooks/queries'; import { useSetupStore } from '@/store/setup-store'; export interface CursorStatus { @@ -15,52 +11,42 @@ export interface CursorStatus { /** * Custom hook for managing Cursor CLI status - * Handles checking CLI installation, authentication, and refresh functionality + * Uses React Query for data fetching with automatic caching. */ export function useCursorStatus() { const { setCursorCliStatus } = useSetupStore(); + const { data: result, isLoading, refetch } = useCursorCliStatus(); - const [status, setStatus] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - const loadData = useCallback(async () => { - setIsLoading(true); - try { - const api = getHttpApiClient(); - const statusResult = await api.setup.getCursorStatus(); - - if (statusResult.success) { - const newStatus = { - installed: statusResult.installed ?? false, - version: statusResult.version ?? undefined, - authenticated: statusResult.auth?.authenticated ?? false, - method: statusResult.auth?.method, - }; - setStatus(newStatus); - - // Also update the global setup store so other components can access the status - setCursorCliStatus({ - installed: newStatus.installed, - version: newStatus.version, - auth: newStatus.authenticated - ? { - authenticated: true, - method: newStatus.method || 'unknown', - } - : undefined, - }); - } - } catch (error) { - logger.error('Failed to load Cursor settings:', error); - toast.error('Failed to load Cursor settings'); - } finally { - setIsLoading(false); - } - }, [setCursorCliStatus]); + // Transform the API result into the local CursorStatus shape + const status = useMemo((): CursorStatus | null => { + if (!result) return null; + return { + installed: result.installed ?? false, + version: result.version ?? undefined, + authenticated: result.auth?.authenticated ?? false, + method: result.auth?.method, + }; + }, [result]); + // Keep the global setup store in sync with query data useEffect(() => { - loadData(); - }, [loadData]); + if (status) { + setCursorCliStatus({ + installed: status.installed, + version: status.version, + auth: status.authenticated + ? { + authenticated: true, + method: status.method || 'unknown', + } + : undefined, + }); + } + }, [status, setCursorCliStatus]); + + const loadData = useCallback(() => { + refetch(); + }, [refetch]); return { status, diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts index 233e0fdd..3542b951 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts @@ -5,59 +5,53 @@ * configuring which sources to load Skills from (user/project). */ -import { useState } from 'react'; +import { useCallback } from 'react'; import { useAppStore } from '@/store/app-store'; import { toast } from 'sonner'; -import { getElectronAPI } from '@/lib/electron'; +import { useUpdateGlobalSettings } from '@/hooks/mutations'; export function useSkillsSettings() { const enabled = useAppStore((state) => state.enableSkills); const sources = useAppStore((state) => state.skillsSources); - const [isLoading, setIsLoading] = useState(false); - const updateEnabled = async (newEnabled: boolean) => { - setIsLoading(true); - try { - const api = getElectronAPI(); - if (!api.settings) { - throw new Error('Settings API not available'); - } - await api.settings.updateGlobal({ enableSkills: newEnabled }); - // Update local store after successful server update - useAppStore.setState({ enableSkills: newEnabled }); - toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled'); - } catch (error) { - toast.error('Failed to update skills settings'); - console.error(error); - } finally { - setIsLoading(false); - } - }; + // React Query mutation (disable default toast) + const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false }); - const updateSources = async (newSources: Array<'user' | 'project'>) => { - setIsLoading(true); - try { - const api = getElectronAPI(); - if (!api.settings) { - throw new Error('Settings API not available'); - } - await api.settings.updateGlobal({ skillsSources: newSources }); - // Update local store after successful server update - useAppStore.setState({ skillsSources: newSources }); - toast.success('Skills sources updated'); - } catch (error) { - toast.error('Failed to update skills sources'); - console.error(error); - } finally { - setIsLoading(false); - } - }; + const updateEnabled = useCallback( + (newEnabled: boolean) => { + updateSettingsMutation.mutate( + { enableSkills: newEnabled }, + { + onSuccess: () => { + useAppStore.setState({ enableSkills: newEnabled }); + toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled'); + }, + } + ); + }, + [updateSettingsMutation] + ); + + const updateSources = useCallback( + (newSources: Array<'user' | 'project'>) => { + updateSettingsMutation.mutate( + { skillsSources: newSources }, + { + onSuccess: () => { + useAppStore.setState({ skillsSources: newSources }); + toast.success('Skills sources updated'); + }, + } + ); + }, + [updateSettingsMutation] + ); return { enabled, sources, updateEnabled, updateSources, - isLoading, + isLoading: updateSettingsMutation.isPending, }; } diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts index ccf7664a..dfc55cd0 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts @@ -5,59 +5,53 @@ * configuring which sources to load Subagents from (user/project). */ -import { useState } from 'react'; +import { useCallback } from 'react'; import { useAppStore } from '@/store/app-store'; import { toast } from 'sonner'; -import { getElectronAPI } from '@/lib/electron'; +import { useUpdateGlobalSettings } from '@/hooks/mutations'; export function useSubagentsSettings() { const enabled = useAppStore((state) => state.enableSubagents); const sources = useAppStore((state) => state.subagentsSources); - const [isLoading, setIsLoading] = useState(false); - const updateEnabled = async (newEnabled: boolean) => { - setIsLoading(true); - try { - const api = getElectronAPI(); - if (!api.settings) { - throw new Error('Settings API not available'); - } - await api.settings.updateGlobal({ enableSubagents: newEnabled }); - // Update local store after successful server update - useAppStore.setState({ enableSubagents: newEnabled }); - toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled'); - } catch (error) { - toast.error('Failed to update subagents settings'); - console.error(error); - } finally { - setIsLoading(false); - } - }; + // React Query mutation (disable default toast) + const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false }); - const updateSources = async (newSources: Array<'user' | 'project'>) => { - setIsLoading(true); - try { - const api = getElectronAPI(); - if (!api.settings) { - throw new Error('Settings API not available'); - } - await api.settings.updateGlobal({ subagentsSources: newSources }); - // Update local store after successful server update - useAppStore.setState({ subagentsSources: newSources }); - toast.success('Subagents sources updated'); - } catch (error) { - toast.error('Failed to update subagents sources'); - console.error(error); - } finally { - setIsLoading(false); - } - }; + const updateEnabled = useCallback( + (newEnabled: boolean) => { + updateSettingsMutation.mutate( + { enableSubagents: newEnabled }, + { + onSuccess: () => { + useAppStore.setState({ enableSubagents: newEnabled }); + toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled'); + }, + } + ); + }, + [updateSettingsMutation] + ); + + const updateSources = useCallback( + (newSources: Array<'user' | 'project'>) => { + updateSettingsMutation.mutate( + { subagentsSources: newSources }, + { + onSuccess: () => { + useAppStore.setState({ subagentsSources: newSources }); + toast.success('Subagents sources updated'); + }, + } + ); + }, + [updateSettingsMutation] + ); return { enabled, sources, updateEnabled, updateSources, - isLoading, + isLoading: updateSettingsMutation.isPending, }; } diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts index 50f82393..475f8378 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts @@ -9,10 +9,12 @@ * Agent definitions in settings JSON are used server-side only. */ -import { useState, useEffect, useCallback } from 'react'; +import { useMemo, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useAppStore } from '@/store/app-store'; import type { AgentDefinition } from '@automaker/types'; -import { getElectronAPI } from '@/lib/electron'; +import { useDiscoveredAgents } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; export type SubagentScope = 'global' | 'project'; export type SubagentType = 'filesystem'; @@ -35,51 +37,40 @@ interface FilesystemAgent { } export function useSubagents() { + const queryClient = useQueryClient(); const currentProject = useAppStore((state) => state.currentProject); - const [isLoading, setIsLoading] = useState(false); - const [subagentsWithScope, setSubagentsWithScope] = useState([]); - // Fetch filesystem agents - const fetchFilesystemAgents = useCallback(async () => { - setIsLoading(true); - try { - const api = getElectronAPI(); - if (!api.settings) { - console.warn('Settings API not available'); - return; - } - const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']); + // Use React Query hook for fetching agents + const { + data: agents = [], + isLoading, + refetch, + } = useDiscoveredAgents(currentProject?.path, ['user', 'project']); - if (data.success && data.agents) { - // Transform filesystem agents to SubagentWithScope format - const agents: SubagentWithScope[] = data.agents.map( - ({ name, definition, source, filePath }: FilesystemAgent) => ({ - name, - definition, - scope: source === 'user' ? 'global' : 'project', - type: 'filesystem' as const, - source, - filePath, - }) - ); - setSubagentsWithScope(agents); - } - } catch (error) { - console.error('Failed to fetch filesystem agents:', error); - } finally { - setIsLoading(false); - } - }, [currentProject?.path]); + // Transform agents to SubagentWithScope format + const subagentsWithScope = useMemo((): SubagentWithScope[] => { + return agents.map(({ name, definition, source, filePath }: FilesystemAgent) => ({ + name, + definition, + scope: source === 'user' ? 'global' : 'project', + type: 'filesystem' as const, + source, + filePath, + })); + }, [agents]); - // Fetch filesystem agents on mount and when project changes - useEffect(() => { - fetchFilesystemAgents(); - }, [fetchFilesystemAgents]); + // Refresh function that invalidates the query cache + const refreshFilesystemAgents = useCallback(async () => { + await queryClient.invalidateQueries({ + queryKey: queryKeys.settings.agents(currentProject?.path ?? ''), + }); + await refetch(); + }, [queryClient, currentProject?.path, refetch]); return { subagentsWithScope, isLoading, hasProject: !!currentProject, - refreshFilesystemAgents: fetchFilesystemAgents, + refreshFilesystemAgents, }; } diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx index 2bf20d82..a5e2772d 100644 --- a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx +++ b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx @@ -1,238 +1,77 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status'; import { OpencodeModelConfiguration } from './opencode-model-configuration'; -import { getElectronAPI } from '@/lib/electron'; -import { createLogger } from '@automaker/utils/logger'; +import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; import type { CliStatus as SharedCliStatus } from '../shared/types'; import type { OpencodeModelId } from '@automaker/types'; import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status'; -const logger = createLogger('OpencodeSettings'); -const OPENCODE_PROVIDER_ID = 'opencode'; -const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|'; -const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([OPENCODE_PROVIDER_ID]); - export function OpencodeSettingsTab() { + const queryClient = useQueryClient(); const { enabledOpencodeModels, opencodeDefaultModel, setOpencodeDefaultModel, toggleOpencodeModel, - setDynamicOpencodeModels, - dynamicOpencodeModels, enabledDynamicModelIds, toggleDynamicModel, - cachedOpencodeProviders, - setCachedOpencodeProviders, } = useAppStore(); - const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false); - const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false); - const [cliStatus, setCliStatus] = useState(null); - const [authStatus, setAuthStatus] = useState(null); const [isSaving, setIsSaving] = useState(false); - const providerRefreshSignatureRef = useRef(''); - // Phase 1: Load CLI status quickly on mount - useEffect(() => { - const checkOpencodeStatus = async () => { - setIsCheckingOpencodeCli(true); - try { - const api = getElectronAPI(); - if (api?.setup?.getOpencodeStatus) { - const result = await api.setup.getOpencodeStatus(); - setCliStatus({ - success: result.success, - status: result.installed ? 'installed' : 'not_installed', - method: result.auth?.method, - version: result.version, - path: result.path, - recommendation: result.recommendation, - installCommands: result.installCommands, - }); - if (result.auth) { - setAuthStatus({ - authenticated: result.auth.authenticated, - method: (result.auth.method as OpencodeAuthStatus['method']) || 'none', - hasApiKey: result.auth.hasApiKey, - hasEnvApiKey: result.auth.hasEnvApiKey, - hasOAuthToken: result.auth.hasOAuthToken, - }); - } - } else { - setCliStatus({ - success: false, - status: 'not_installed', - recommendation: 'OpenCode CLI detection is only available in desktop mode.', - }); - } - } catch (error) { - logger.error('Failed to check OpenCode CLI status:', error); - setCliStatus({ - success: false, - status: 'not_installed', - error: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCheckingOpencodeCli(false); - } + // React Query hooks for data fetching + const { + data: cliStatusData, + isLoading: isCheckingOpencodeCli, + refetch: refetchCliStatus, + } = useOpencodeCliStatus(); + + const isCliInstalled = cliStatusData?.installed ?? false; + + const { data: providersData = [], isFetching: isFetchingProviders } = useOpencodeProviders(); + + const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels(); + + // Transform CLI status to the expected format + const cliStatus = useMemo((): SharedCliStatus | null => { + if (!cliStatusData) return null; + return { + success: cliStatusData.success ?? false, + status: cliStatusData.installed ? 'installed' : 'not_installed', + method: cliStatusData.auth?.method, + version: cliStatusData.version, + path: cliStatusData.path, + recommendation: cliStatusData.recommendation, + installCommands: cliStatusData.installCommands, }; - checkOpencodeStatus(); - }, []); + }, [cliStatusData]); - // Phase 2: Load dynamic models and providers in background (only if not cached) - useEffect(() => { - const loadDynamicContent = async () => { - const api = getElectronAPI(); - const isInstalled = cliStatus?.success && cliStatus?.status === 'installed'; - - if (!isInstalled || !api?.setup) return; - - // Skip if already have cached data - const needsProviders = cachedOpencodeProviders.length === 0; - const needsModels = dynamicOpencodeModels.length === 0; - - if (!needsProviders && !needsModels) return; - - setIsLoadingDynamicModels(true); - try { - // Load providers if needed - if (needsProviders && api.setup.getOpencodeProviders) { - const providersResult = await api.setup.getOpencodeProviders(); - if (providersResult.success && providersResult.providers) { - setCachedOpencodeProviders(providersResult.providers); - } - } - - // Load models if needed - if (needsModels && api.setup.getOpencodeModels) { - const modelsResult = await api.setup.getOpencodeModels(); - if (modelsResult.success && modelsResult.models) { - setDynamicOpencodeModels(modelsResult.models); - } - } - } catch (error) { - logger.error('Failed to load dynamic content:', error); - } finally { - setIsLoadingDynamicModels(false); - } + // Transform auth status to the expected format + const authStatus = useMemo((): OpencodeAuthStatus | null => { + if (!cliStatusData?.auth) return null; + return { + authenticated: cliStatusData.auth.authenticated, + method: (cliStatusData.auth.method as OpencodeAuthStatus['method']) || 'none', + hasApiKey: cliStatusData.auth.hasApiKey, + hasEnvApiKey: cliStatusData.auth.hasEnvApiKey, + hasOAuthToken: cliStatusData.auth.hasOAuthToken, }; - loadDynamicContent(); - }, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - const refreshModelsForNewProviders = async () => { - const api = getElectronAPI(); - const isInstalled = cliStatus?.success && cliStatus?.status === 'installed'; - - if (!isInstalled || !api?.setup?.refreshOpencodeModels) return; - if (isLoadingDynamicModels) return; - - const authenticatedProviders = cachedOpencodeProviders - .filter((provider) => provider.authenticated) - .map((provider) => provider.id) - .filter((providerId) => !OPENCODE_STATIC_MODEL_PROVIDERS.has(providerId)); - - if (authenticatedProviders.length === 0) { - providerRefreshSignatureRef.current = ''; - return; - } - - const dynamicProviderIds = new Set( - dynamicOpencodeModels.map((model) => model.provider).filter(Boolean) - ); - const missingProviders = authenticatedProviders.filter( - (providerId) => !dynamicProviderIds.has(providerId) - ); - - if (missingProviders.length === 0) { - providerRefreshSignatureRef.current = ''; - return; - } - - const signature = [...missingProviders].sort().join(OPENCODE_PROVIDER_SIGNATURE_SEPARATOR); - if (providerRefreshSignatureRef.current === signature) return; - providerRefreshSignatureRef.current = signature; - - setIsLoadingDynamicModels(true); - try { - const modelsResult = await api.setup.refreshOpencodeModels(); - if (modelsResult.success && modelsResult.models) { - setDynamicOpencodeModels(modelsResult.models); - } - } catch (error) { - logger.error('Failed to refresh OpenCode models for new providers:', error); - } finally { - setIsLoadingDynamicModels(false); - } - }; - - refreshModelsForNewProviders(); - }, [ - cachedOpencodeProviders, - dynamicOpencodeModels, - cliStatus?.success, - cliStatus?.status, - isLoadingDynamicModels, - setDynamicOpencodeModels, - ]); + }, [cliStatusData]); + // Refresh all opencode-related queries const handleRefreshOpencodeCli = useCallback(async () => { - setIsCheckingOpencodeCli(true); - setIsLoadingDynamicModels(true); - try { - const api = getElectronAPI(); - if (api?.setup?.getOpencodeStatus) { - const result = await api.setup.getOpencodeStatus(); - setCliStatus({ - success: result.success, - status: result.installed ? 'installed' : 'not_installed', - method: result.auth?.method, - version: result.version, - path: result.path, - recommendation: result.recommendation, - installCommands: result.installCommands, - }); - if (result.auth) { - setAuthStatus({ - authenticated: result.auth.authenticated, - method: (result.auth.method as OpencodeAuthStatus['method']) || 'none', - hasApiKey: result.auth.hasApiKey, - hasEnvApiKey: result.auth.hasEnvApiKey, - hasOAuthToken: result.auth.hasOAuthToken, - }); - } - - if (result.installed) { - // Refresh providers - if (api?.setup?.getOpencodeProviders) { - const providersResult = await api.setup.getOpencodeProviders(); - if (providersResult.success && providersResult.providers) { - setCachedOpencodeProviders(providersResult.providers); - } - } - - // Refresh dynamic models - if (api?.setup?.refreshOpencodeModels) { - const modelsResult = await api.setup.refreshOpencodeModels(); - if (modelsResult.success && modelsResult.models) { - setDynamicOpencodeModels(modelsResult.models); - } - } - - toast.success('OpenCode CLI refreshed'); - } - } - } catch (error) { - logger.error('Failed to refresh OpenCode CLI status:', error); - toast.error('Failed to refresh OpenCode CLI status'); - } finally { - setIsCheckingOpencodeCli(false); - setIsLoadingDynamicModels(false); - } - }, [setDynamicOpencodeModels, setCachedOpencodeProviders]); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.cli.opencode() }), + queryClient.invalidateQueries({ queryKey: queryKeys.models.opencodeProviders() }), + queryClient.invalidateQueries({ queryKey: queryKeys.models.opencode() }), + ]); + await refetchCliStatus(); + toast.success('OpenCode CLI refreshed'); + }, [queryClient, refetchCliStatus]); const handleDefaultModelChange = useCallback( (model: OpencodeModelId) => { @@ -240,7 +79,7 @@ export function OpencodeSettingsTab() { try { setOpencodeDefaultModel(model); toast.success('Default model updated'); - } catch (error) { + } catch { toast.error('Failed to update default model'); } finally { setIsSaving(false); @@ -254,7 +93,7 @@ export function OpencodeSettingsTab() { setIsSaving(true); try { toggleOpencodeModel(model, enabled); - } catch (error) { + } catch { toast.error('Failed to update models'); } finally { setIsSaving(false); @@ -268,7 +107,7 @@ export function OpencodeSettingsTab() { setIsSaving(true); try { toggleDynamicModel(modelId, enabled); - } catch (error) { + } catch { toast.error('Failed to update dynamic model'); } finally { setIsSaving(false); @@ -286,14 +125,14 @@ export function OpencodeSettingsTab() { ); } - const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed'; + const isLoadingDynamicModels = isFetchingProviders || isFetchingModels; return (
@@ -306,8 +145,8 @@ export function OpencodeSettingsTab() { isSaving={isSaving} onDefaultModelChange={handleDefaultModelChange} onModelToggle={handleModelToggle} - providers={cachedOpencodeProviders as OpenCodeProviderInfo[]} - dynamicModels={dynamicOpencodeModels} + providers={providersData as OpenCodeProviderInfo[]} + dynamicModels={modelsData} enabledDynamicModelIds={enabledDynamicModelIds} onDynamicModelToggle={handleDynamicModelToggle} isLoadingDynamicModels={isLoadingDynamicModels} diff --git a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx index 2d232a65..dd3b42f0 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -14,24 +14,16 @@ import { PanelBottomClose, } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch'; -import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; import { getHttpApiClient } from '@/lib/http-api-client'; +import { useWorktreeInitScript } from '@/hooks/queries'; +import { useSetInitScript, useDeleteInitScript } from '@/hooks/mutations'; interface WorktreesSectionProps { useWorktrees: boolean; onUseWorktreesChange: (value: boolean) => void; } -interface InitScriptResponse { - success: boolean; - exists: boolean; - content: string; - path: string; - error?: string; -} - export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) { const currentProject = useAppStore((s) => s.currentProject); const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator); @@ -40,12 +32,20 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch); const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator); const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator); + + // Local state for script content editing const [scriptContent, setScriptContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); - const [scriptExists, setScriptExists] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); + + // React Query hooks for init script + const { data: initScriptData, isLoading } = useWorktreeInitScript(currentProject?.path); + const setInitScript = useSetInitScript(currentProject?.path ?? ''); + const deleteInitScript = useDeleteInitScript(currentProject?.path ?? ''); + + // Derived state + const scriptExists = initScriptData?.exists ?? false; + const isSaving = setInitScript.isPending; + const isDeleting = deleteInitScript.isPending; // Get the current show indicator setting const showIndicator = currentProject?.path @@ -65,102 +65,43 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre // Check if there are unsaved changes const hasChanges = scriptContent !== originalContent; - // Load init script content when project changes + // Sync query data to local state when it changes useEffect(() => { - if (!currentProject?.path) { + if (initScriptData) { + const content = initScriptData.content || ''; + setScriptContent(content); + setOriginalContent(content); + } else if (!currentProject?.path) { setScriptContent(''); setOriginalContent(''); - setScriptExists(false); - setIsLoading(false); - return; } + }, [initScriptData, currentProject?.path]); - const loadInitScript = async () => { - setIsLoading(true); - try { - const response = await apiGet( - `/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}` - ); - if (response.success) { - const content = response.content || ''; - setScriptContent(content); - setOriginalContent(content); - setScriptExists(response.exists); - } - } catch (error) { - console.error('Failed to load init script:', error); - } finally { - setIsLoading(false); - } - }; - - loadInitScript(); - }, [currentProject?.path]); - - // Save script - const handleSave = useCallback(async () => { + // Save script using mutation + const handleSave = useCallback(() => { if (!currentProject?.path) return; - - setIsSaving(true); - try { - const response = await apiPut<{ success: boolean; error?: string }>( - '/api/worktree/init-script', - { - projectPath: currentProject.path, - content: scriptContent, - } - ); - if (response.success) { + setInitScript.mutate(scriptContent, { + onSuccess: () => { setOriginalContent(scriptContent); - setScriptExists(true); - toast.success('Init script saved'); - } else { - toast.error('Failed to save init script', { - description: response.error, - }); - } - } catch (error) { - console.error('Failed to save init script:', error); - toast.error('Failed to save init script'); - } finally { - setIsSaving(false); - } - }, [currentProject?.path, scriptContent]); + }, + }); + }, [currentProject?.path, scriptContent, setInitScript]); // Reset to original content const handleReset = useCallback(() => { setScriptContent(originalContent); }, [originalContent]); - // Delete script - const handleDelete = useCallback(async () => { + // Delete script using mutation + const handleDelete = useCallback(() => { if (!currentProject?.path) return; - - setIsDeleting(true); - try { - const response = await apiDelete<{ success: boolean; error?: string }>( - '/api/worktree/init-script', - { - body: { projectPath: currentProject.path }, - } - ); - if (response.success) { + deleteInitScript.mutate(undefined, { + onSuccess: () => { setScriptContent(''); setOriginalContent(''); - setScriptExists(false); - toast.success('Init script deleted'); - } else { - toast.error('Failed to delete init script', { - description: response.error, - }); - } - } catch (error) { - console.error('Failed to delete init script:', error); - toast.error('Failed to delete init script'); - } finally { - setIsDeleting(false); - } - }, [currentProject?.path]); + }, + }); + }, [currentProject?.path, deleteInitScript]); // Handle content change (no auto-save) const handleContentChange = useCallback((value: string) => { From 5fe7bcd378a14be9aff1909231371aafc75f0c39 Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:22:17 +0100 Subject: [PATCH 10/76] refactor(ui): migrate usage popovers and running agents to React Query - Migrate claude-usage-popover to useClaudeUsage query with polling - Migrate codex-usage-popover to useCodexUsage query with polling - Migrate usage-popover to React Query hooks - Migrate running-agents-view to useRunningAgents query - Replace manual polling intervals with refetchInterval - Remove manual loading/error state management Co-Authored-By: Claude Opus 4.5 --- .../src/components/claude-usage-popover.tsx | 146 +++---------- .../ui/src/components/codex-usage-popover.tsx | 114 +++-------- apps/ui/src/components/usage-popover.tsx | 192 +++++------------- .../components/views/running-agents-view.tsx | 97 +++------ 4 files changed, 134 insertions(+), 415 deletions(-) diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index d51e316c..2516c35b 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -1,114 +1,39 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +/** + * Claude Usage Popover + * + * Displays Claude API usage statistics using React Query for data fetching. + */ + +import { useState, useMemo } from 'react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { getElectronAPI } from '@/lib/electron'; -import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; - -// Error codes for distinguishing failure modes -const ERROR_CODES = { - API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE', - AUTH_ERROR: 'AUTH_ERROR', - TRUST_PROMPT: 'TRUST_PROMPT', - UNKNOWN: 'UNKNOWN', -} as const; - -type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; - -type UsageError = { - code: ErrorCode; - message: string; -}; - -// Fixed refresh interval (45 seconds) -const REFRESH_INTERVAL_SECONDS = 45; +import { useClaudeUsage } from '@/hooks/queries'; export function ClaudeUsagePopover() { - const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); // Check if CLI is verified/authenticated const isCliVerified = claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated'; - // Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes + // Use React Query for usage data + const { + data: claudeUsage, + isLoading, + isFetching, + error, + dataUpdatedAt, + refetch, + } = useClaudeUsage(isCliVerified); + + // Check if data is stale (older than 2 minutes) const isStale = useMemo(() => { - return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; - }, [claudeUsageLastUpdated]); - - const fetchUsage = useCallback( - async (isAutoRefresh = false) => { - if (!isAutoRefresh) setLoading(true); - setError(null); - try { - const api = getElectronAPI(); - if (!api.claude) { - setError({ - code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, - message: 'Claude API bridge not available', - }); - return; - } - const data = await api.claude.getUsage(); - if ('error' in data) { - // Detect trust prompt error - const isTrustPrompt = - data.error === 'Trust prompt pending' || - (data.message && data.message.includes('folder permission')); - setError({ - code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR, - message: data.message || data.error, - }); - return; - } - setClaudeUsage(data); - } catch (err) { - setError({ - code: ERROR_CODES.UNKNOWN, - message: err instanceof Error ? err.message : 'Failed to fetch usage', - }); - } finally { - if (!isAutoRefresh) setLoading(false); - } - }, - [setClaudeUsage] - ); - - // Auto-fetch on mount if data is stale (only if CLI is verified) - useEffect(() => { - if (isStale && isCliVerified) { - fetchUsage(true); - } - }, [isStale, isCliVerified, fetchUsage]); - - useEffect(() => { - // Skip if CLI is not verified - if (!isCliVerified) return; - - // Initial fetch when opened - if (open) { - if (!claudeUsage || isStale) { - fetchUsage(); - } - } - - // Auto-refresh interval (only when open) - let intervalId: NodeJS.Timeout | null = null; - if (open) { - intervalId = setInterval(() => { - fetchUsage(true); - }, REFRESH_INTERVAL_SECONDS * 1000); - } - - return () => { - if (intervalId) clearInterval(intervalId); - }; - }, [open, claudeUsage, isStale, isCliVerified, fetchUsage]); + return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000; + }, [dataUpdatedAt]); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -143,7 +68,6 @@ export function ClaudeUsagePopover() { isPrimary?: boolean; stale?: boolean; }) => { - // Check if percentage is valid (not NaN, not undefined, is a finite number) const isValidPercentage = typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage); const safePercentage = isValidPercentage ? percentage : 0; @@ -244,10 +168,10 @@ export function ClaudeUsagePopover() { )}
@@ -258,26 +182,16 @@ export function ClaudeUsagePopover() {
-

{error.message}

+

+ {error instanceof Error ? error.message : 'Failed to fetch usage'} +

- {error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( - 'Ensure the Electron bridge is running or restart the app' - ) : error.code === ERROR_CODES.TRUST_PROMPT ? ( - <> - Run claude in your - terminal and approve access to continue - - ) : ( - <> - Make sure Claude CLI is installed and authenticated via{' '} - claude login - - )} + Make sure Claude CLI is installed and authenticated via{' '} + claude login

- ) : !claudeUsage ? ( - // Loading state + ) : isLoading || !claudeUsage ? (

Loading usage data...

diff --git a/apps/ui/src/components/codex-usage-popover.tsx b/apps/ui/src/components/codex-usage-popover.tsx index f6005b6a..8f3da55d 100644 --- a/apps/ui/src/components/codex-usage-popover.tsx +++ b/apps/ui/src/components/codex-usage-popover.tsx @@ -1,11 +1,10 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useMemo } from 'react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { getElectronAPI } from '@/lib/electron'; -import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; +import { useCodexUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -22,9 +21,6 @@ type UsageError = { message: string; }; -// Fixed refresh interval (45 seconds) -const REFRESH_INTERVAL_SECONDS = 45; - // Helper to format reset time function formatResetTime(unixTimestamp: number): string { const date = new Date(unixTimestamp * 1000); @@ -62,95 +58,39 @@ function getWindowLabel(durationMins: number): { title: string; subtitle: string } export function CodexUsagePopover() { - const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); // Check if Codex is authenticated const isCodexAuthenticated = codexAuthStatus?.authenticated; + // Use React Query for data fetching with automatic polling + const { + data: codexUsage, + isLoading, + isFetching, + error: queryError, + dataUpdatedAt, + refetch, + } = useCodexUsage(isCodexAuthenticated); + // Check if data is stale (older than 2 minutes) const isStale = useMemo(() => { - return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; - }, [codexUsageLastUpdated]); + return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000; + }, [dataUpdatedAt]); - const fetchUsage = useCallback( - async (isAutoRefresh = false) => { - if (!isAutoRefresh) setLoading(true); - setError(null); - try { - const api = getElectronAPI(); - if (!api.codex) { - setError({ - code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, - message: 'Codex API bridge not available', - }); - return; - } - const data = await api.codex.getUsage(); - if ('error' in data) { - // Check if it's the "not available" error - if ( - data.message?.includes('not available') || - data.message?.includes('does not provide') - ) { - setError({ - code: ERROR_CODES.NOT_AVAILABLE, - message: data.message || data.error, - }); - } else { - setError({ - code: ERROR_CODES.AUTH_ERROR, - message: data.message || data.error, - }); - } - return; - } - setCodexUsage(data); - } catch (err) { - setError({ - code: ERROR_CODES.UNKNOWN, - message: err instanceof Error ? err.message : 'Failed to fetch usage', - }); - } finally { - if (!isAutoRefresh) setLoading(false); - } - }, - [setCodexUsage] - ); - - // Auto-fetch on mount if data is stale (only if authenticated) - useEffect(() => { - if (isStale && isCodexAuthenticated) { - fetchUsage(true); + // Convert query error to UsageError format for backward compatibility + const error = useMemo((): UsageError | null => { + if (!queryError) return null; + const message = queryError instanceof Error ? queryError.message : String(queryError); + if (message.includes('not available') || message.includes('does not provide')) { + return { code: ERROR_CODES.NOT_AVAILABLE, message }; } - }, [isStale, isCodexAuthenticated, fetchUsage]); - - useEffect(() => { - // Skip if not authenticated - if (!isCodexAuthenticated) return; - - // Initial fetch when opened - if (open) { - if (!codexUsage || isStale) { - fetchUsage(); - } + if (message.includes('bridge') || message.includes('API')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; } - - // Auto-refresh interval (only when open) - let intervalId: NodeJS.Timeout | null = null; - if (open) { - intervalId = setInterval(() => { - fetchUsage(true); - }, REFRESH_INTERVAL_SECONDS * 1000); - } - - return () => { - if (intervalId) clearInterval(intervalId); - }; - }, [open, codexUsage, isStale, isCodexAuthenticated, fetchUsage]); + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [queryError]); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -288,10 +228,10 @@ export function CodexUsagePopover() { )}
diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index fa8a6a1b..a4db928a 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -1,13 +1,12 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { getElectronAPI } from '@/lib/electron'; -import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { useClaudeUsage, useCodexUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -60,22 +59,63 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s } export function UsagePopover() { - const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); - const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude'); - const [claudeLoading, setClaudeLoading] = useState(false); - const [codexLoading, setCodexLoading] = useState(false); - const [claudeError, setClaudeError] = useState(null); - const [codexError, setCodexError] = useState(null); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; + // Use React Query hooks for usage data + // Only enable polling when popover is open AND the tab is active + const { + data: claudeUsage, + isLoading: claudeLoading, + error: claudeQueryError, + dataUpdatedAt: claudeUsageLastUpdated, + refetch: refetchClaude, + } = useClaudeUsage(open && activeTab === 'claude' && isClaudeAuthenticated); + + const { + data: codexUsage, + isLoading: codexLoading, + error: codexQueryError, + dataUpdatedAt: codexUsageLastUpdated, + refetch: refetchCodex, + } = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated); + + // Parse errors into structured format + const claudeError = useMemo((): UsageError | null => { + if (!claudeQueryError) return null; + const message = + claudeQueryError instanceof Error ? claudeQueryError.message : String(claudeQueryError); + // Detect trust prompt error + const isTrustPrompt = message.includes('Trust prompt') || message.includes('folder permission'); + if (isTrustPrompt) { + return { code: ERROR_CODES.TRUST_PROMPT, message }; + } + if (message.includes('API bridge')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; + } + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [claudeQueryError]); + + const codexError = useMemo((): UsageError | null => { + if (!codexQueryError) return null; + const message = + codexQueryError instanceof Error ? codexQueryError.message : String(codexQueryError); + if (message.includes('not available') || message.includes('does not provide')) { + return { code: ERROR_CODES.NOT_AVAILABLE, message }; + } + if (message.includes('API bridge')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; + } + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [codexQueryError]); + // Determine which tab to show by default useEffect(() => { if (isClaudeAuthenticated) { @@ -94,137 +134,9 @@ export function UsagePopover() { return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; }, [codexUsageLastUpdated]); - const fetchClaudeUsage = useCallback( - async (isAutoRefresh = false) => { - if (!isAutoRefresh) setClaudeLoading(true); - setClaudeError(null); - try { - const api = getElectronAPI(); - if (!api.claude) { - setClaudeError({ - code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, - message: 'Claude API bridge not available', - }); - return; - } - const data = await api.claude.getUsage(); - if ('error' in data) { - // Detect trust prompt error - const isTrustPrompt = - data.error === 'Trust prompt pending' || - (data.message && data.message.includes('folder permission')); - setClaudeError({ - code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR, - message: data.message || data.error, - }); - return; - } - setClaudeUsage(data); - } catch (err) { - setClaudeError({ - code: ERROR_CODES.UNKNOWN, - message: err instanceof Error ? err.message : 'Failed to fetch usage', - }); - } finally { - if (!isAutoRefresh) setClaudeLoading(false); - } - }, - [setClaudeUsage] - ); - - const fetchCodexUsage = useCallback( - async (isAutoRefresh = false) => { - if (!isAutoRefresh) setCodexLoading(true); - setCodexError(null); - try { - const api = getElectronAPI(); - if (!api.codex) { - setCodexError({ - code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, - message: 'Codex API bridge not available', - }); - return; - } - const data = await api.codex.getUsage(); - if ('error' in data) { - if ( - data.message?.includes('not available') || - data.message?.includes('does not provide') - ) { - setCodexError({ - code: ERROR_CODES.NOT_AVAILABLE, - message: data.message || data.error, - }); - } else { - setCodexError({ - code: ERROR_CODES.AUTH_ERROR, - message: data.message || data.error, - }); - } - return; - } - setCodexUsage(data); - } catch (err) { - setCodexError({ - code: ERROR_CODES.UNKNOWN, - message: err instanceof Error ? err.message : 'Failed to fetch usage', - }); - } finally { - if (!isAutoRefresh) setCodexLoading(false); - } - }, - [setCodexUsage] - ); - - // Auto-fetch on mount if data is stale - useEffect(() => { - if (isClaudeStale && isClaudeAuthenticated) { - fetchClaudeUsage(true); - } - }, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]); - - useEffect(() => { - if (isCodexStale && isCodexAuthenticated) { - fetchCodexUsage(true); - } - }, [isCodexStale, isCodexAuthenticated, fetchCodexUsage]); - - // Auto-refresh when popover is open - useEffect(() => { - if (!open) return; - - // Fetch based on active tab - if (activeTab === 'claude' && isClaudeAuthenticated) { - if (!claudeUsage || isClaudeStale) { - fetchClaudeUsage(); - } - const intervalId = setInterval(() => { - fetchClaudeUsage(true); - }, REFRESH_INTERVAL_SECONDS * 1000); - return () => clearInterval(intervalId); - } - - if (activeTab === 'codex' && isCodexAuthenticated) { - if (!codexUsage || isCodexStale) { - fetchCodexUsage(); - } - const intervalId = setInterval(() => { - fetchCodexUsage(true); - }, REFRESH_INTERVAL_SECONDS * 1000); - return () => clearInterval(intervalId); - } - }, [ - open, - activeTab, - claudeUsage, - isClaudeStale, - isClaudeAuthenticated, - codexUsage, - isCodexStale, - isCodexAuthenticated, - fetchClaudeUsage, - fetchCodexUsage, - ]); + // Refetch functions for manual refresh + const fetchClaudeUsage = () => refetchClaude(); + const fetchCodexUsage = () => refetchCodex(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx index 17e14b25..4575b84e 100644 --- a/apps/ui/src/components/views/running-agents-view.tsx +++ b/apps/ui/src/components/views/running-agents-view.tsx @@ -1,95 +1,47 @@ -import { useState, useEffect, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; +/** + * Running Agents View + * + * Displays all currently running agents across all projects. + * Uses React Query for data fetching with automatic polling. + */ + +import { useState, useCallback } from 'react'; import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react'; -import { getElectronAPI, RunningAgent } from '@/lib/electron'; +import type { RunningAgent } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { useNavigate } from '@tanstack/react-router'; import { AgentOutputModal } from './board-view/dialogs/agent-output-modal'; - -const logger = createLogger('RunningAgentsView'); +import { useRunningAgents } from '@/hooks/queries'; +import { useStopFeature } from '@/hooks/mutations'; export function RunningAgentsView() { - const [runningAgents, setRunningAgents] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); const [selectedAgent, setSelectedAgent] = useState(null); const { setCurrentProject, projects } = useAppStore(); const navigate = useNavigate(); - const fetchRunningAgents = useCallback(async () => { - try { - const api = getElectronAPI(); - if (api.runningAgents) { - const result = await api.runningAgents.getAll(); - if (result.success && result.runningAgents) { - setRunningAgents(result.runningAgents); - } - } - } catch (error) { - logger.error('Error fetching running agents:', error); - } finally { - setLoading(false); - setRefreshing(false); - } - }, []); + // Use React Query for running agents with auto-refresh + const { data, isLoading, isFetching, refetch } = useRunningAgents(); - // Initial fetch - useEffect(() => { - fetchRunningAgents(); - }, [fetchRunningAgents]); + const runningAgents = data?.agents ?? []; - // Auto-refresh every 2 seconds - useEffect(() => { - const interval = setInterval(() => { - fetchRunningAgents(); - }, 2000); - - return () => clearInterval(interval); - }, [fetchRunningAgents]); - - // Subscribe to auto-mode events to update in real-time - useEffect(() => { - const api = getElectronAPI(); - if (!api.autoMode) return; - - const unsubscribe = api.autoMode.onEvent((event) => { - // When a feature completes or errors, refresh the list - if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') { - fetchRunningAgents(); - } - }); - - return () => { - unsubscribe(); - }; - }, [fetchRunningAgents]); + // Use mutation for stopping features + const stopFeature = useStopFeature(); const handleRefresh = useCallback(() => { - setRefreshing(true); - fetchRunningAgents(); - }, [fetchRunningAgents]); + refetch(); + }, [refetch]); const handleStopAgent = useCallback( - async (featureId: string) => { - try { - const api = getElectronAPI(); - if (api.autoMode) { - await api.autoMode.stopFeature(featureId); - // Refresh list after stopping - fetchRunningAgents(); - } - } catch (error) { - logger.error('Error stopping agent:', error); - } + (featureId: string) => { + stopFeature.mutate(featureId); }, - [fetchRunningAgents] + [stopFeature] ); const handleNavigateToProject = useCallback( (agent: RunningAgent) => { - // Find the project by path const project = projects.find((p) => p.path === agent.projectPath); if (project) { setCurrentProject(project); @@ -103,7 +55,7 @@ export function RunningAgentsView() { setSelectedAgent(agent); }, []); - if (loading) { + if (isLoading) { return (
@@ -128,8 +80,8 @@ export function RunningAgentsView() {

- @@ -217,6 +169,7 @@ export function RunningAgentsView() { variant="destructive" size="sm" onClick={() => handleStopAgent(agent.featureId)} + disabled={stopFeature.isPending} > Stop From c2fed78733721a3c5c9aecd7f8f014dd994fde62 Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:22:39 +0100 Subject: [PATCH 11/76] refactor(ui): migrate remaining components to React Query - Migrate workspace-picker-modal to useWorkspaceDirectories query - Migrate session-manager to useSessions query - Migrate git-diff-panel to useGitDiffs query - Migrate prompt-list to useIdeationPrompts query - Migrate spec-view hooks to useSpecFile query and spec mutations - Migrate use-board-background-settings to useProjectSettings query - Migrate use-guided-prompts to useIdeationPrompts query - Migrate use-project-settings-loader to React Query - Complete React Query migration across all components Co-Authored-By: Claude Opus 4.5 --- .../dialogs/workspace-picker-modal.tsx | 47 ++---- apps/ui/src/components/session-manager.tsx | 63 ++++---- apps/ui/src/components/ui/git-diff-panel.tsx | 84 +++++----- .../ideation-view/components/prompt-list.tsx | 64 ++++---- .../spec-view/hooks/use-spec-generation.ts | 144 +++++++---------- .../views/spec-view/hooks/use-spec-loading.ts | 84 +++++----- .../views/spec-view/hooks/use-spec-save.ts | 24 +-- .../hooks/use-board-background-settings.ts | 34 ++-- apps/ui/src/hooks/use-guided-prompts.ts | 53 +++---- .../src/hooks/use-project-settings-loader.ts | 145 +++++++++--------- 10 files changed, 308 insertions(+), 434 deletions(-) diff --git a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx index 4f287465..5c144fd7 100644 --- a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx +++ b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx @@ -1,4 +1,3 @@ -import { useState, useEffect, useCallback } from 'react'; import { Dialog, DialogContent, @@ -9,7 +8,7 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react'; -import { getHttpApiClient } from '@/lib/http-api-client'; +import { useWorkspaceDirectories } from '@/hooks/queries'; interface WorkspaceDirectory { name: string; @@ -23,41 +22,15 @@ interface WorkspacePickerModalProps { } export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) { - const [isLoading, setIsLoading] = useState(false); - const [directories, setDirectories] = useState([]); - const [error, setError] = useState(null); - - const loadDirectories = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - const client = getHttpApiClient(); - const result = await client.workspace.getDirectories(); - - if (result.success && result.directories) { - setDirectories(result.directories); - } else { - setError(result.error || 'Failed to load directories'); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load directories'); - } finally { - setIsLoading(false); - } - }, []); - - // Load directories when modal opens - useEffect(() => { - if (open) { - loadDirectories(); - } - }, [open, loadDirectories]); + // React Query hook - only fetch when modal is open + const { data: directories = [], isLoading, error, refetch } = useWorkspaceDirectories(open); const handleSelect = (dir: WorkspaceDirectory) => { onSelect(dir.path, dir.name); }; + const errorMessage = error instanceof Error ? error.message : null; + return ( @@ -79,19 +52,19 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace )} - {error && !isLoading && ( + {errorMessage && !isLoading && (
-

{error}

-
)} - {!isLoading && !error && directories.length === 0 && ( + {!isLoading && !errorMessage && directories.length === 0 && (
@@ -102,7 +75,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
)} - {!isLoading && !error && directories.length > 0 && ( + {!isLoading && !errorMessage && directories.length > 0 && (
{directories.map((dir) => ( @@ -411,7 +411,7 @@ export function UsagePopover() { variant="ghost" size="icon" className={cn('h-6 w-6', codexLoading && 'opacity-80')} - onClick={() => !codexLoading && fetchCodexUsage(false)} + onClick={() => !codexLoading && fetchCodexUsage()} > diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts index eeca9729..7b84dfe9 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts @@ -22,9 +22,11 @@ export function useBranches() { const branches = branchData?.branches ?? []; const aheadCount = branchData?.aheadCount ?? 0; const behindCount = branchData?.behindCount ?? 0; + // Use conservative defaults (false) until data is confirmed + // This prevents the UI from assuming git capabilities before the query completes const gitRepoStatus: GitRepoStatus = { - isGitRepo: branchData?.isGitRepo ?? true, - hasCommits: branchData?.hasCommits ?? true, + isGitRepo: branchData?.isGitRepo ?? false, + hasCommits: branchData?.hasCommits ?? false, }; const fetchBranches = useCallback( diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx index 4575b84e..326146e1 100644 --- a/apps/ui/src/components/views/running-agents-view.tsx +++ b/apps/ui/src/components/views/running-agents-view.tsx @@ -34,8 +34,8 @@ export function RunningAgentsView() { }, [refetch]); const handleStopAgent = useCallback( - (featureId: string) => { - stopFeature.mutate(featureId); + (featureId: string, projectPath: string) => { + stopFeature.mutate({ featureId, projectPath }); }, [stopFeature] ); @@ -168,7 +168,7 @@ export function RunningAgentsView() {
@@ -353,7 +354,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa )} > {isProcessing ? ( - + ) : ( )} diff --git a/apps/ui/src/components/dialogs/new-project-modal.tsx b/apps/ui/src/components/dialogs/new-project-modal.tsx index dd114bf9..55df0a1c 100644 --- a/apps/ui/src/components/dialogs/new-project-modal.tsx +++ b/apps/ui/src/components/dialogs/new-project-modal.tsx @@ -14,16 +14,8 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; -import { - FolderPlus, - FolderOpen, - Rocket, - ExternalLink, - Check, - Loader2, - Link, - Folder, -} from 'lucide-react'; +import { FolderPlus, FolderOpen, Rocket, ExternalLink, Check, Link, Folder } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { starterTemplates, type StarterTemplate } from '@/lib/templates'; import { getElectronAPI } from '@/lib/electron'; import { cn } from '@/lib/utils'; @@ -451,7 +443,7 @@ export function NewProjectModal({ > {isCreating ? ( <> - + {activeTab === 'template' ? 'Cloning...' : 'Creating...'} ) : ( diff --git a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx index 4f287465..84e723fc 100644 --- a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx +++ b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx @@ -8,7 +8,8 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react'; +import { Folder, FolderOpen, AlertCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { getHttpApiClient } from '@/lib/http-api-client'; interface WorkspaceDirectory { @@ -74,7 +75,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
{isLoading && (
- +

Loading projects...

)} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index 3cda8229..c4956159 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -1,9 +1,9 @@ import type { NavigateOptions } from '@tanstack/react-router'; -import { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { formatShortcut } from '@/store/app-store'; import type { NavSection } from '../types'; import type { Project } from '@/lib/electron'; +import { Spinner } from '@/components/ui/spinner'; interface SidebarNavigationProps { currentProject: Project | null; @@ -93,9 +93,10 @@ export function SidebarNavigation({ >
{item.isLoading ? ( - diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx index 88c31acc..f0fa9a45 100644 --- a/apps/ui/src/components/session-manager.tsx +++ b/apps/ui/src/components/session-manager.tsx @@ -16,8 +16,8 @@ import { Check, X, ArchiveRestore, - Loader2, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import type { SessionListItem } from '@/types/electron'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; @@ -466,7 +466,7 @@ export function SessionManager({ {/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */} {(currentSessionId === session.id && isCurrentSessionThinking) || runningSessions.has(session.id) ? ( - + ) : ( )} diff --git a/apps/ui/src/components/ui/button.tsx b/apps/ui/src/components/ui/button.tsx index fa970a52..a7163ed3 100644 --- a/apps/ui/src/components/ui/button.tsx +++ b/apps/ui/src/components/ui/button.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; -import { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { Spinner } from '@/components/ui/spinner'; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]", @@ -39,7 +39,7 @@ const buttonVariants = cva( // Loading spinner component function ButtonSpinner({ className }: { className?: string }) { - return
) : !claudeUsage ? (
- +

Loading usage data...

) : ( @@ -568,7 +569,7 @@ export function UsagePopover() {
) : !codexUsage ? (
- +

Loading usage data...

) : codexUsage.rateLimits ? ( diff --git a/apps/ui/src/components/views/agent-tools-view.tsx b/apps/ui/src/components/views/agent-tools-view.tsx index 4485f165..48c3f92d 100644 --- a/apps/ui/src/components/views/agent-tools-view.tsx +++ b/apps/ui/src/components/views/agent-tools-view.tsx @@ -11,12 +11,12 @@ import { Terminal, CheckCircle, XCircle, - Loader2, Play, File, Pencil, Wrench, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { getElectronAPI } from '@/lib/electron'; @@ -236,7 +236,7 @@ export function AgentToolsView() { > {isReadingFile ? ( <> - + Reading... ) : ( @@ -315,7 +315,7 @@ export function AgentToolsView() { > {isWritingFile ? ( <> - + Writing... ) : ( @@ -383,7 +383,7 @@ export function AgentToolsView() { > {isRunningCommand ? ( <> - + Running... ) : ( diff --git a/apps/ui/src/components/views/agent-view/components/thinking-indicator.tsx b/apps/ui/src/components/views/agent-view/components/thinking-indicator.tsx index facd4fc5..ff2965d5 100644 --- a/apps/ui/src/components/views/agent-view/components/thinking-indicator.tsx +++ b/apps/ui/src/components/views/agent-view/components/thinking-indicator.tsx @@ -1,4 +1,5 @@ import { Bot } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; export function ThinkingIndicator() { return ( @@ -8,20 +9,7 @@ export function ThinkingIndicator() {
-
- - - -
+ Thinking...
diff --git a/apps/ui/src/components/views/analysis-view.tsx b/apps/ui/src/components/views/analysis-view.tsx index e235a9e9..2143d390 100644 --- a/apps/ui/src/components/views/analysis-view.tsx +++ b/apps/ui/src/components/views/analysis-view.tsx @@ -14,12 +14,12 @@ import { RefreshCw, BarChart3, FileCode, - Loader2, FileText, CheckCircle, AlertCircle, ListChecks, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn, generateUUID } from '@/lib/utils'; const logger = createLogger('AnalysisView'); @@ -742,7 +742,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
{children}
diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 957fccc0..fb6deeae 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -11,7 +11,7 @@ import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@autom import type { ModelProvider } from '@automaker/types'; import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants'; import { useEffect } from 'react'; -import { RefreshCw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; interface ModelSelectorProps { selectedModel: string; // Can be ModelAlias or "cursor-{id}" @@ -294,7 +294,7 @@ export function ModelSelector({ {/* Loading state */} {codexModelsLoading && dynamicCodexModels.length === 0 && (
- + Loading models...
)} diff --git a/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx b/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx index 66af8d13..5c9bb5db 100644 --- a/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx @@ -6,12 +6,12 @@ import { ClipboardList, FileText, ScrollText, - Loader2, Check, Eye, RefreshCw, Sparkles, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -236,7 +236,7 @@ export function PlanningModeSelector({
{isGenerating ? ( <> - + Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}... diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx index c7e7b7ef..0f6d2af3 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx @@ -8,7 +8,8 @@ import { DropdownMenuTrigger, DropdownMenuLabel, } from '@/components/ui/dropdown-menu'; -import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react'; +import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import type { WorktreeInfo, BranchInfo } from '../types'; @@ -81,7 +82,7 @@ export function BranchSwitchDropdown({
{isLoadingBranches ? ( - + Loading branches... ) : filteredBranches.length === 0 ? ( diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx index 859ad34c..8405fbca 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { - Loader2, Terminal, ArrowDown, ExternalLink, @@ -12,6 +11,7 @@ import { Clock, GitBranch, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer'; import { useDevServerLogs } from '../hooks/use-dev-server-logs'; @@ -183,7 +183,7 @@ export function DevServerLogsPanel({ onClick={() => fetchLogs()} title="Refresh logs" > - + {isLoading ? : }
@@ -234,7 +234,7 @@ export function DevServerLogsPanel({ > {isLoading && !logs ? (
- + Loading logs...
) : !logs && !isRunning ? ( @@ -245,7 +245,7 @@ export function DevServerLogsPanel({ ) : !logs ? (
-
+

Waiting for output...

Logs will appear as the server generates output diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx index 52a07c96..079c9b11 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx @@ -7,7 +7,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react'; +import { GitBranch, ChevronDown, CircleDot, Check } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import type { WorktreeInfo } from '../types'; @@ -44,7 +45,7 @@ export function WorktreeMobileDropdown({ {displayBranch} {isActivating ? ( - + ) : ( )} @@ -74,7 +75,7 @@ export function WorktreeMobileDropdown({ ) : (

)} - {isRunning && } + {isRunning && } {worktree.branch} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 5cb379d3..212e6d89 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -1,6 +1,7 @@ import type { JSX } from 'react'; import { Button } from '@/components/ui/button'; -import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react'; +import { Globe, CircleDot, GitPullRequest } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; @@ -197,8 +198,8 @@ export function WorktreeTab({ aria-label={worktree.branch} data-testid={`worktree-branch-${worktree.branch}`} > - {isRunning && } - {isActivating && !isRunning && } + {isRunning && } + {isActivating && !isRunning && } {worktree.branch} {cardCount !== undefined && cardCount > 0 && ( @@ -264,8 +265,8 @@ export function WorktreeTab({ : 'Click to switch to this branch' } > - {isRunning && } - {isActivating && !isRunning && } + {isRunning && } + {isActivating && !isRunning && } {worktree.branch} {cardCount !== undefined && cardCount > 0 && ( diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 2cc844f4..fbd54d73 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { Button } from '@/components/ui/button'; import { GitBranch, Plus, RefreshCw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; import { getHttpApiClient } from '@/lib/http-api-client'; @@ -285,7 +286,7 @@ export function WorktreePanel({ disabled={isLoading} title="Refresh worktrees" > - + {isLoading ? : } )} @@ -429,7 +430,7 @@ export function WorktreePanel({ disabled={isLoading} title="Refresh worktrees" > - + {isLoading ? : }
diff --git a/apps/ui/src/components/views/code-view.tsx b/apps/ui/src/components/views/code-view.tsx index 581a298b..ce80bc23 100644 --- a/apps/ui/src/components/views/code-view.tsx +++ b/apps/ui/src/components/views/code-view.tsx @@ -4,7 +4,8 @@ import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react'; +import { File, Folder, FolderOpen, ChevronRight, ChevronDown, Code } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; const logger = createLogger('CodeView'); @@ -206,7 +207,7 @@ export function CodeView() { if (isLoading) { return (
- +
); } diff --git a/apps/ui/src/components/views/context-view.tsx b/apps/ui/src/components/views/context-view.tsx index 024ee392..b186e0c1 100644 --- a/apps/ui/src/components/views/context-view.tsx +++ b/apps/ui/src/components/views/context-view.tsx @@ -12,7 +12,6 @@ import { HeaderActionsPanelTrigger, } from '@/components/ui/header-actions-panel'; import { - RefreshCw, FileText, Image as ImageIcon, Trash2, @@ -24,9 +23,9 @@ import { Pencil, FilePlus, FileUp, - Loader2, MoreVertical, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig, @@ -670,7 +669,7 @@ export function ContextView() { if (isLoading) { return (
- +
); } @@ -790,7 +789,7 @@ export function ContextView() { {isUploading && (
- + Uploading {uploadingFileName}...
@@ -838,7 +837,7 @@ export function ContextView() { {file.name} {isGenerating ? ( - + Generating description... ) : file.description ? ( @@ -955,7 +954,7 @@ export function ContextView() { {generatingDescriptions.has(selectedFile.name) ? (
- + Generating description with AI...
) : selectedFile.description ? ( diff --git a/apps/ui/src/components/views/dashboard-view.tsx b/apps/ui/src/components/views/dashboard-view.tsx index 7e657c80..f9582d00 100644 --- a/apps/ui/src/components/views/dashboard-view.tsx +++ b/apps/ui/src/components/views/dashboard-view.tsx @@ -18,7 +18,6 @@ import { Folder, Star, Clock, - Loader2, ChevronDown, MessageSquare, MoreVertical, @@ -28,6 +27,7 @@ import { type LucideIcon, } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Input } from '@/components/ui/input'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { @@ -992,7 +992,7 @@ export function DashboardView() { data-testid="project-opening-overlay" >
- +

Opening project...

diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx index 3ff836dc..cc62a7fe 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx @@ -4,7 +4,6 @@ import { X, Wand2, ExternalLink, - Loader2, CheckCircle, Clock, GitPullRequest, @@ -14,6 +13,7 @@ import { ChevronDown, ChevronUp, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -87,7 +87,7 @@ export function IssueDetailPanel({ if (isValidating) { return ( ); @@ -297,9 +297,7 @@ export function IssueDetailPanel({ Comments {totalCount > 0 && `(${totalCount})`} - {commentsLoading && ( - - )} + {commentsLoading && } {commentsExpanded ? ( ) : ( @@ -340,7 +338,7 @@ export function IssueDetailPanel({ > {loadingMore ? ( <> - + Loading... ) : ( diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx index bf6496f1..01bf8316 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx @@ -2,12 +2,12 @@ import { Circle, CheckCircle2, ExternalLink, - Loader2, CheckCircle, Sparkles, GitPullRequest, User, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import type { IssueRowProps } from '../types'; @@ -97,7 +97,7 @@ export function IssueRow({ {/* Validating indicator */} {isValidating && ( - + Analyzing... )} diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx index 1c58bbe4..5b599c4e 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -1,5 +1,6 @@ import { CircleDot, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import type { IssuesStateFilter } from '../types'; import { IssuesFilterControls } from './issues-filter-controls'; @@ -77,7 +78,7 @@ export function IssuesListHeader({
diff --git a/apps/ui/src/components/views/github-prs-view.tsx b/apps/ui/src/components/views/github-prs-view.tsx index 855d136c..fbbcb9eb 100644 --- a/apps/ui/src/components/views/github-prs-view.tsx +++ b/apps/ui/src/components/views/github-prs-view.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { createLogger } from '@automaker/utils/logger'; -import { GitPullRequest, Loader2, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react'; +import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI, GitHubPR } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; @@ -86,7 +87,7 @@ export function GitHubPRsView() { if (loading) { return (
- +
); } @@ -134,7 +135,7 @@ export function GitHubPRsView() { diff --git a/apps/ui/src/components/views/graph-view-page.tsx b/apps/ui/src/components/views/graph-view-page.tsx index f8e9ba0a..47acf313 100644 --- a/apps/ui/src/components/views/graph-view-page.tsx +++ b/apps/ui/src/components/views/graph-view-page.tsx @@ -17,7 +17,7 @@ import { import { useWorktrees } from './board-view/worktree-panel/hooks'; import { useAutoMode } from '@/hooks/use-auto-mode'; import { pathsEqual } from '@/lib/utils'; -import { RefreshCw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { createLogger } from '@automaker/utils/logger'; import { toast } from 'sonner'; @@ -330,7 +330,7 @@ export function GraphViewPage() { if (isLoading) { return (
- +
); } diff --git a/apps/ui/src/components/views/ideation-view/components/ideation-dashboard.tsx b/apps/ui/src/components/views/ideation-view/components/ideation-dashboard.tsx index 41d12a34..8bf6d7bb 100644 --- a/apps/ui/src/components/views/ideation-view/components/ideation-dashboard.tsx +++ b/apps/ui/src/components/views/ideation-view/components/ideation-dashboard.tsx @@ -4,7 +4,8 @@ */ import { useState, useMemo, useEffect, useCallback } from 'react'; -import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react'; +import { AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -109,7 +110,7 @@ function SuggestionCard({ )} > {isAdding ? ( - + ) : ( <> @@ -153,11 +154,7 @@ function GeneratingCard({ job }: { job: GenerationJob }) { isError ? 'bg-destructive/10 text-destructive' : 'bg-blue-500/10 text-blue-500' )} > - {isError ? ( - - ) : ( - - )} + {isError ? : }

{job.prompt.title}

diff --git a/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx index a4d3d505..c09548b0 100644 --- a/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx +++ b/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx @@ -13,8 +13,8 @@ import { Gauge, Accessibility, BarChart3, - Loader2, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Card, CardContent } from '@/components/ui/card'; import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import type { IdeaCategory } from '@automaker/types'; @@ -53,7 +53,7 @@ export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps {isLoading && (
- + Loading categories...
)} diff --git a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx index a7e3fc8b..af52030b 100644 --- a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx +++ b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx @@ -3,7 +3,8 @@ */ import { useState, useMemo } from 'react'; -import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react'; +import { ArrowLeft, Lightbulb, CheckCircle2 } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Card, CardContent } from '@/components/ui/card'; import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { useIdeationStore } from '@/store/ideation-store'; @@ -121,7 +122,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
{isLoadingPrompts && (
- + Loading prompts...
)} @@ -162,7 +163,7 @@ export function PromptList({ category, onBack }: PromptListProps) { }`} > {isLoading || isGenerating ? ( - + ) : isStarted ? ( ) : ( diff --git a/apps/ui/src/components/views/ideation-view/index.tsx b/apps/ui/src/components/views/ideation-view/index.tsx index 0662c6ed..50cbd8d3 100644 --- a/apps/ui/src/components/views/ideation-view/index.tsx +++ b/apps/ui/src/components/views/ideation-view/index.tsx @@ -11,7 +11,8 @@ import { PromptList } from './components/prompt-list'; import { IdeationDashboard } from './components/ideation-dashboard'; import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { Button } from '@/components/ui/button'; -import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Loader2, Trash2 } from 'lucide-react'; +import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import type { IdeaCategory } from '@automaker/types'; import type { IdeationMode } from '@/store/ideation-store'; @@ -152,11 +153,7 @@ function IdeationHeader({ className="gap-2" disabled={isAcceptingAll} > - {isAcceptingAll ? ( - - ) : ( - - )} + {isAcceptingAll ? : } Accept All ({acceptAllCount}) )} diff --git a/apps/ui/src/components/views/interview-view.tsx b/apps/ui/src/components/views/interview-view.tsx index b9b9997e..b56971c1 100644 --- a/apps/ui/src/components/views/interview-view.tsx +++ b/apps/ui/src/components/views/interview-view.tsx @@ -5,7 +5,8 @@ import { useAppStore, Feature } from '@/store/app-store'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react'; +import { Bot, Send, User, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn, generateUUID } from '@/lib/utils'; import { getElectronAPI } from '@/lib/electron'; import { Markdown } from '@/components/ui/markdown'; @@ -491,7 +492,7 @@ export function InterviewView() {
- + Generating specification...
@@ -571,7 +572,7 @@ export function InterviewView() { > {isGenerating ? ( <> - + Creating... ) : ( diff --git a/apps/ui/src/components/views/login-view.tsx b/apps/ui/src/components/views/login-view.tsx index faca109c..0ed259bf 100644 --- a/apps/ui/src/components/views/login-view.tsx +++ b/apps/ui/src/components/views/login-view.tsx @@ -24,7 +24,8 @@ import { } from '@/lib/http-api-client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react'; +import { KeyRound, AlertCircle, RefreshCw, ServerCrash } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useAuthStore } from '@/store/auth-store'; import { useSetupStore } from '@/store/setup-store'; @@ -349,7 +350,7 @@ export function LoginView() { return (
- +

Connecting to server {state.attempt > 1 ? ` (attempt ${state.attempt}/${MAX_RETRIES})` : '...'} @@ -385,7 +386,7 @@ export function LoginView() { return (

- +

{state.phase === 'checking_setup' ? 'Loading settings...' : 'Redirecting...'}

@@ -447,7 +448,7 @@ export function LoginView() { > {isLoggingIn ? ( <> - + Authenticating... ) : ( diff --git a/apps/ui/src/components/views/memory-view.tsx b/apps/ui/src/components/views/memory-view.tsx index 66533413..b6331602 100644 --- a/apps/ui/src/components/views/memory-view.tsx +++ b/apps/ui/src/components/views/memory-view.tsx @@ -19,6 +19,7 @@ import { FilePlus, MoreVertical, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Dialog, DialogContent, @@ -299,7 +300,7 @@ export function MemoryView() { if (isLoading) { return (
- +
); } diff --git a/apps/ui/src/components/views/notifications-view.tsx b/apps/ui/src/components/views/notifications-view.tsx index aaffb011..08386c55 100644 --- a/apps/ui/src/components/views/notifications-view.tsx +++ b/apps/ui/src/components/views/notifications-view.tsx @@ -9,7 +9,8 @@ import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notific import { getHttpApiClient } from '@/lib/http-api-client'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react'; +import { Bell, Check, CheckCheck, Trash2, ExternalLink } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useNavigate } from '@tanstack/react-router'; import type { Notification } from '@automaker/types'; @@ -146,7 +147,7 @@ export function NotificationsView() { if (isLoading) { return (
- +

Loading notifications...

); diff --git a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx index c289d382..d6d0c247 100644 --- a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx +++ b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx @@ -10,9 +10,9 @@ import { Save, RotateCcw, Trash2, - Loader2, PanelBottomClose, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch'; import { toast } from 'sonner'; @@ -409,7 +409,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti {isLoading ? (
- +
) : ( <> @@ -448,11 +448,7 @@ npm install disabled={!scriptExists || isSaving || isDeleting} className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10" > - {isDeleting ? ( - - ) : ( - - )} + {isDeleting ? : } Delete
diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx index d46729c1..b77518d0 100644 --- a/apps/ui/src/components/views/running-agents-view.tsx +++ b/apps/ui/src/components/views/running-agents-view.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { createLogger } from '@automaker/utils/logger'; -import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react'; +import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI, RunningAgent } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; @@ -146,7 +147,7 @@ export function RunningAgentsView() { if (loading) { return (
- +
); } @@ -169,7 +170,11 @@ export function RunningAgentsView() {
diff --git a/apps/ui/src/components/views/settings-view/account/account-section.tsx b/apps/ui/src/components/views/settings-view/account/account-section.tsx index 901e5040..d10049fc 100644 --- a/apps/ui/src/components/views/settings-view/account/account-section.tsx +++ b/apps/ui/src/components/views/settings-view/account/account-section.tsx @@ -11,6 +11,7 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from 'sonner'; import { LogOut, User, Code2, RefreshCw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { logout } from '@/lib/http-api-client'; import { useAuthStore } from '@/store/auth-store'; @@ -143,7 +144,7 @@ export function AccountSection() { disabled={isRefreshing || isLoadingEditors} className="shrink-0 h-9 w-9" > - + {isRefreshing ? : } diff --git a/apps/ui/src/components/views/settings-view/api-keys/api-key-field.tsx b/apps/ui/src/components/views/settings-view/api-keys/api-key-field.tsx index 6d044f6c..61b49a1c 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/api-key-field.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/api-key-field.tsx @@ -1,7 +1,8 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from 'lucide-react'; +import { AlertCircle, CheckCircle2, Eye, EyeOff, Zap } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import type { ProviderConfig } from '@/config/api-providers'; interface ApiKeyFieldProps { @@ -70,7 +71,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) { > {testButton.loading ? ( <> - + Testing... ) : ( diff --git a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx index 088f3ddf..840c8e63 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx @@ -1,7 +1,8 @@ import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { Button } from '@/components/ui/button'; -import { Key, CheckCircle2, Trash2, Loader2 } from 'lucide-react'; +import { Key, CheckCircle2, Trash2 } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { ApiKeyField } from './api-key-field'; import { buildProviderConfigs } from '@/config/api-providers'; import { SecurityNotice } from './security-notice'; @@ -142,7 +143,7 @@ export function ApiKeysSection() { data-testid="delete-anthropic-key" > {isDeletingAnthropicKey ? ( - + ) : ( )} @@ -159,7 +160,7 @@ export function ApiKeysSection() { data-testid="delete-openai-key" > {isDeletingOpenaiKey ? ( - + ) : ( )} diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx index 11912ec4..2aa1ff3c 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -4,6 +4,7 @@ import { getElectronAPI } from '@/lib/electron'; import { useSetupStore } from '@/store/setup-store'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { RefreshCw, AlertCircle } from 'lucide-react'; const ERROR_NO_API = 'Claude usage API not available'; @@ -178,7 +179,7 @@ export function ClaudeUsageSection() { data-testid="refresh-claude-usage" title={CLAUDE_REFRESH_LABEL} > - + {isLoading ? : }

{CLAUDE_USAGE_SUBTITLE}

diff --git a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx index 2457969b..a6474a7a 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; @@ -172,7 +173,7 @@ export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: C 'transition-all duration-200' )} > - + {isChecking ? : }

diff --git a/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx b/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx index dd194c1f..6e577787 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button'; import { CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; @@ -56,7 +57,7 @@ export function CliStatusCard({ 'transition-all duration-200' )} > - + {isChecking ? : }

{description}

diff --git a/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx index 86635264..3e0d8b53 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; @@ -165,7 +166,7 @@ export function CodexCliStatus({ status, authStatus, isChecking, onRefresh }: Cl 'transition-all duration-200' )} > - + {isChecking ? : }

diff --git a/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx index bc49270c..68c052fb 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { CursorIcon } from '@/components/ui/provider-icon'; @@ -290,7 +291,7 @@ export function CursorCliStatus({ status, isChecking, onRefresh }: CursorCliStat 'transition-all duration-200' )} > - + {isChecking ? : }

diff --git a/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx index bfd9efe6..7d7577c5 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Spinner } from '@/components/ui/spinner'; import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; @@ -221,7 +222,7 @@ export function OpencodeCliStatus({ 'transition-all duration-200' )} > - + {isChecking ? : }

diff --git a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx index b879df4a..9012047d 100644 --- a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import { useCallback, useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { RefreshCw, AlertCircle } from 'lucide-react'; import { OpenAIIcon } from '@/components/ui/provider-icon'; import { cn } from '@/lib/utils'; @@ -168,7 +169,7 @@ export function CodexUsageSection() { data-testid="refresh-codex-usage" title={CODEX_REFRESH_LABEL} > - + {isLoading ? : }

{CODEX_USAGE_SUBTITLE}

diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx index 780f5f98..e9c5a071 100644 --- a/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx +++ b/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { History, @@ -184,7 +185,11 @@ export function EventHistoryView() {

{events.length > 0 && ( diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx index babf4bda..752b06e7 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx @@ -1,4 +1,5 @@ -import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -111,7 +112,7 @@ export function MCPServerCard({ className="h-8 px-2" > {testState?.status === 'testing' ? ( - + ) : ( )} diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx index a85fc305..8caf3bca 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx @@ -1,5 +1,6 @@ import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; interface MCPServerHeaderProps { @@ -43,7 +44,7 @@ export function MCPServerHeader({ disabled={isRefreshing} data-testid="refresh-mcp-servers-button" > - + {isRefreshing ? : } {hasServers && ( <> diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/utils.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/utils.tsx index 25102025..83687556 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/utils.tsx +++ b/apps/ui/src/components/views/settings-view/mcp-servers/utils.tsx @@ -1,4 +1,5 @@ -import { Terminal, Globe, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { Terminal, Globe, CheckCircle2, XCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import type { ServerType, ServerTestState } from './types'; import { SENSITIVE_PARAM_PATTERNS } from './constants'; @@ -40,7 +41,7 @@ export function getServerIcon(type: ServerType = 'stdio') { export function getTestStatusIcon(status: ServerTestState['status']) { switch (status) { case 'testing': - return ; + return ; case 'success': return ; case 'error': diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx index 08800331..d1f1bf76 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx @@ -14,16 +14,8 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; -import { - Bot, - RefreshCw, - Loader2, - Users, - ExternalLink, - Globe, - FolderOpen, - Sparkles, -} from 'lucide-react'; +import { Bot, RefreshCw, Users, ExternalLink, Globe, FolderOpen, Sparkles } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useSubagents } from './hooks/use-subagents'; import { useSubagentsSettings } from './hooks/use-subagents-settings'; import { SubagentCard } from './subagent-card'; @@ -178,11 +170,7 @@ export function SubagentsSection() { title="Refresh agents from disk" className="gap-1.5 h-7 px-2 text-xs" > - {isLoadingAgents ? ( - - ) : ( - - )} + {isLoadingAgents ? : } Refresh
diff --git a/apps/ui/src/components/views/settings-view/providers/cursor-permissions-section.tsx b/apps/ui/src/components/views/settings-view/providers/cursor-permissions-section.tsx index 29be25b3..133913b9 100644 --- a/apps/ui/src/components/views/settings-view/providers/cursor-permissions-section.tsx +++ b/apps/ui/src/components/views/settings-view/providers/cursor-permissions-section.tsx @@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Shield, ShieldCheck, ShieldAlert, ChevronDown, Copy, Check } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import type { CursorStatus } from '../hooks/use-cursor-status'; import type { PermissionsData } from '../hooks/use-cursor-permissions'; @@ -118,7 +119,7 @@ export function CursorPermissionsSection({ {isLoadingPermissions ? (
-
+
) : ( <> diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx index 3d2d0fb6..6ecce79c 100644 --- a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx @@ -9,7 +9,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react'; +import { Terminal, Cloud, Cpu, Brain, Github, KeyRound, ShieldCheck } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import type { @@ -500,7 +501,7 @@ export function OpencodeModelConfiguration({

{isLoadingDynamicModels && (
- + Discovering...
)} diff --git a/apps/ui/src/components/views/setup-view/components/cli-installation-card.tsx b/apps/ui/src/components/views/setup-view/components/cli-installation-card.tsx index ee32f231..4932ef29 100644 --- a/apps/ui/src/components/views/setup-view/components/cli-installation-card.tsx +++ b/apps/ui/src/components/views/setup-view/components/cli-installation-card.tsx @@ -1,6 +1,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Download, Loader2, AlertCircle } from 'lucide-react'; +import { Download, AlertCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { CopyableCommandField } from './copyable-command-field'; import { TerminalOutput } from './terminal-output'; @@ -59,7 +60,7 @@ export function CliInstallationCard({ > {isInstalling ? ( <> - + Installing... ) : ( diff --git a/apps/ui/src/components/views/setup-view/components/status-badge.tsx b/apps/ui/src/components/views/setup-view/components/status-badge.tsx index 38692a0b..53869d07 100644 --- a/apps/ui/src/components/views/setup-view/components/status-badge.tsx +++ b/apps/ui/src/components/views/setup-view/components/status-badge.tsx @@ -1,4 +1,5 @@ -import { CheckCircle2, XCircle, Loader2, AlertCircle } from 'lucide-react'; +import { CheckCircle2, XCircle, AlertCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; interface StatusBadgeProps { status: @@ -34,7 +35,7 @@ export function StatusBadge({ status, label }: StatusBadgeProps) { }; case 'checking': return { - icon: , + icon: , className: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20', }; case 'unverified': diff --git a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx index 8b56f49c..87bf6f77 100644 --- a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -14,7 +14,6 @@ import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, Key, ArrowRight, ArrowLeft, @@ -27,6 +26,7 @@ import { XCircle, Trash2, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge, TerminalOutput } from '../components'; import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks'; @@ -330,7 +330,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps Authentication Methods
@@ -412,7 +412,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps > {isInstalling ? ( <> - + Installing... ) : ( @@ -435,7 +435,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps {/* CLI Verification Status */} {cliVerificationStatus === 'verifying' && (
- +

Verifying CLI authentication...

Running a test query

@@ -494,7 +494,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps > {cliVerificationStatus === 'verifying' ? ( <> - + Verifying... ) : cliVerificationStatus === 'error' ? ( @@ -574,7 +574,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps > {isSavingApiKey ? ( <> - + Saving... ) : ( @@ -589,11 +589,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps className="border-red-500/50 text-red-500 hover:bg-red-500/10 hover:text-red-400" data-testid="delete-anthropic-key-button" > - {isDeletingApiKey ? ( - - ) : ( - - )} + {isDeletingApiKey ? : } )}
@@ -602,7 +598,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps {/* API Key Verification Status */} {apiKeyVerificationStatus === 'verifying' && (
- +

Verifying API key...

Running a test query

@@ -642,7 +638,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps > {apiKeyVerificationStatus === 'verifying' ? ( <> - + Verifying... ) : apiKeyVerificationStatus === 'error' ? ( diff --git a/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx index cf581f8c..031d6815 100644 --- a/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx @@ -14,7 +14,6 @@ import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, Key, ArrowRight, ArrowLeft, @@ -27,6 +26,7 @@ import { XCircle, Trash2, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge, TerminalOutput } from '../components'; import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks'; @@ -332,7 +332,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup Authentication Methods
Choose one of the following methods to authenticate: @@ -408,7 +408,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup > {isInstalling ? ( <> - + Installing... ) : ( @@ -427,7 +427,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup {cliVerificationStatus === 'verifying' && (
- +

Verifying CLI authentication...

Running a test query

@@ -605,7 +605,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup > {cliVerificationStatus === 'verifying' ? ( <> - + Verifying... ) : cliVerificationStatus === 'error' ? ( @@ -681,7 +681,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup > {isSavingApiKey ? ( <> - + Saving... ) : ( @@ -696,11 +696,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup className="border-red-500/50 text-red-500 hover:bg-red-500/10 hover:text-red-400" data-testid={config.testIds.deleteApiKeyButton} > - {isDeletingApiKey ? ( - - ) : ( - - )} + {isDeletingApiKey ? : } )}
@@ -708,7 +704,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup {apiKeyVerificationStatus === 'verifying' && (
- +

Verifying API key...

Running a test query

@@ -767,7 +763,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup > {apiKeyVerificationStatus === 'verifying' ? ( <> - + Verifying... ) : apiKeyVerificationStatus === 'error' ? ( diff --git a/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx index ff591f1a..e48057c4 100644 --- a/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx @@ -7,7 +7,6 @@ import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, ArrowRight, ArrowLeft, ExternalLink, @@ -16,6 +15,7 @@ import { AlertTriangle, XCircle, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge } from '../components'; import { CursorIcon } from '@/components/ui/provider-icon'; @@ -204,7 +204,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps
{getStatusBadge()}
@@ -318,7 +318,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -332,7 +332,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps {/* Loading State */} {isChecking && (
- +

Checking Cursor CLI status...

diff --git a/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx index fcccb618..3a20ee24 100644 --- a/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx @@ -6,7 +6,6 @@ import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, ArrowRight, ArrowLeft, ExternalLink, @@ -16,6 +15,7 @@ import { Github, XCircle, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge } from '../components'; @@ -116,7 +116,7 @@ export function GitHubSetupStep({ onNext, onBack, onSkip }: GitHubSetupStepProps
{getStatusBadge()}
@@ -252,7 +252,7 @@ export function GitHubSetupStep({ onNext, onBack, onSkip }: GitHubSetupStepProps {/* Loading State */} {isChecking && (
- +

Checking GitHub CLI status...

diff --git a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx index 5e7e29c0..58337851 100644 --- a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx @@ -7,7 +7,6 @@ import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, ArrowRight, ArrowLeft, ExternalLink, @@ -17,6 +16,7 @@ import { XCircle, Terminal, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge } from '../components'; @@ -204,7 +204,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
{getStatusBadge()}
@@ -316,7 +316,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -330,7 +330,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP {/* Loading State */} {isChecking && (
- +

Checking OpenCode CLI status...

diff --git a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx index b9ad3263..53b3ca0b 100644 --- a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx @@ -17,7 +17,6 @@ import { ArrowRight, ArrowLeft, CheckCircle2, - Loader2, Key, ExternalLink, Copy, @@ -29,6 +28,7 @@ import { Terminal, AlertCircle, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; @@ -240,7 +240,7 @@ function ClaudeContent() { onClick={checkStatus} disabled={isChecking || isVerifying} > - + {isChecking || isVerifying ? : }
@@ -278,7 +278,7 @@ function ClaudeContent() { {/* Checking/Verifying State */} {(isChecking || isVerifying) && (
- +

{isChecking ? 'Checking Claude CLI status...' : 'Verifying authentication...'}

@@ -322,7 +322,7 @@ function ClaudeContent() { > {isInstalling ? ( <> - + Installing... ) : ( @@ -417,11 +417,7 @@ function ClaudeContent() { disabled={isSavingApiKey || !apiKey.trim()} className="flex-1 bg-brand-500 hover:bg-brand-600 text-white" > - {isSavingApiKey ? ( - - ) : ( - 'Save API Key' - )} + {isSavingApiKey ? : 'Save API Key'} {hasApiKey && (
@@ -658,7 +654,7 @@ function CursorContent() { > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -671,7 +667,7 @@ function CursorContent() { {isChecking && (
- +

Checking Cursor CLI status...

)} @@ -807,7 +803,7 @@ function CodexContent() { Codex CLI Status
@@ -915,7 +911,7 @@ function CodexContent() { > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -958,7 +954,7 @@ function CodexContent() { disabled={isSaving || !apiKey.trim()} className="w-full bg-brand-500 hover:bg-brand-600 text-white" > - {isSaving ? : 'Save API Key'} + {isSaving ? : 'Save API Key'} @@ -968,7 +964,7 @@ function CodexContent() { {isChecking && (
- +

Checking Codex CLI status...

)} @@ -1082,7 +1078,7 @@ function OpencodeContent() { OpenCode CLI Status
@@ -1191,7 +1187,7 @@ function OpencodeContent() { > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -1204,7 +1200,7 @@ function OpencodeContent() { {isChecking && (
- +

Checking OpenCode CLI status...

)} @@ -1416,7 +1412,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) ); case 'verifying': return ( - + ); case 'installed_not_auth': return ( @@ -1436,7 +1432,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) {isInitialChecking && (
- +

Checking provider status...

)} diff --git a/apps/ui/src/components/views/spec-view.tsx b/apps/ui/src/components/views/spec-view.tsx index 616dc4dd..88fec94b 100644 --- a/apps/ui/src/components/views/spec-view.tsx +++ b/apps/ui/src/components/views/spec-view.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { RefreshCw } from 'lucide-react'; import { useAppStore } from '@/store/app-store'; +import { Spinner } from '@/components/ui/spinner'; // Extracted hooks import { useSpecLoading, useSpecSave, useSpecGeneration } from './spec-view/hooks'; @@ -86,7 +86,7 @@ export function SpecView() { if (isLoading) { return (
- +
); } diff --git a/apps/ui/src/components/views/spec-view/components/spec-empty-state.tsx b/apps/ui/src/components/views/spec-view/components/spec-empty-state.tsx index fa1792b1..ce7c1667 100644 --- a/apps/ui/src/components/views/spec-view/components/spec-empty-state.tsx +++ b/apps/ui/src/components/views/spec-view/components/spec-empty-state.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button'; -import { FileText, FilePlus2, Loader2 } from 'lucide-react'; +import { FileText, FilePlus2 } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { PHASE_LABELS } from '../constants'; interface SpecEmptyStateProps { @@ -36,7 +37,7 @@ export function SpecEmptyState({ {isProcessing && (
- +
@@ -64,7 +65,7 @@ export function SpecEmptyState({
{isCreating ? ( - + ) : ( )} diff --git a/apps/ui/src/components/views/spec-view/components/spec-header.tsx b/apps/ui/src/components/views/spec-view/components/spec-header.tsx index b38a6579..72d879dd 100644 --- a/apps/ui/src/components/views/spec-view/components/spec-header.tsx +++ b/apps/ui/src/components/views/spec-view/components/spec-header.tsx @@ -3,7 +3,8 @@ import { HeaderActionsPanel, HeaderActionsPanelTrigger, } from '@/components/ui/header-actions-panel'; -import { Save, Sparkles, Loader2, FileText, AlertCircle, ListPlus, RefreshCcw } from 'lucide-react'; +import { Save, Sparkles, FileText, AlertCircle, ListPlus, RefreshCcw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { PHASE_LABELS } from '../constants'; interface SpecHeaderProps { @@ -59,7 +60,7 @@ export function SpecHeader({ {isProcessing && (
- +
@@ -83,7 +84,7 @@ export function SpecHeader({ {/* Mobile processing indicator */} {isProcessing && (
- + Processing...
)} @@ -157,7 +158,7 @@ export function SpecHeader({ {/* Status messages in panel */} {isProcessing && (
- +
{isSyncing diff --git a/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx b/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx index 73389f78..f77b08ca 100644 --- a/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx +++ b/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx @@ -1,4 +1,5 @@ -import { Sparkles, Clock, Loader2 } from 'lucide-react'; +import { Sparkles, Clock } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Dialog, DialogContent, @@ -163,7 +164,7 @@ export function CreateSpecDialog({ > {isCreatingSpec ? ( <> - + Generating... ) : ( diff --git a/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx b/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx index fd534a58..c911fc94 100644 --- a/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx +++ b/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx @@ -1,4 +1,5 @@ -import { Sparkles, Clock, Loader2 } from 'lucide-react'; +import { Sparkles, Clock } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Dialog, DialogContent, @@ -158,7 +159,7 @@ export function RegenerateSpecDialog({ > {isRegenerating ? ( <> - + Regenerating... ) : ( diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 328afc21..0287ca68 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -7,13 +7,13 @@ import { Unlock, SplitSquareHorizontal, SplitSquareVertical, - Loader2, AlertCircle, RefreshCw, X, SquarePlus, Settings, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { getServerUrlSync } from '@/lib/http-api-client'; import { useAppStore, @@ -1279,7 +1279,7 @@ export function TerminalView() { if (loading) { return (
- +
); } @@ -1342,7 +1342,7 @@ export function TerminalView() { {authError &&

{authError}

} +
+ + ))} +
+ )} + +
+ ); +} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx new file mode 100644 index 00000000..cfec2d78 --- /dev/null +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx @@ -0,0 +1,30 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Lightbulb } from 'lucide-react'; +import { ArrayFieldEditor } from './array-field-editor'; + +interface CapabilitiesSectionProps { + capabilities: string[]; + onChange: (capabilities: string[]) => void; +} + +export function CapabilitiesSection({ capabilities, onChange }: CapabilitiesSectionProps) { + return ( + + + + + Core Capabilities + + + + + + + ); +} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx new file mode 100644 index 00000000..1cdbac2f --- /dev/null +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx @@ -0,0 +1,261 @@ +import { Plus, X, ChevronDown, ChevronUp, FolderOpen } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { ListChecks } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import type { SpecOutput } from '@automaker/spec-parser'; + +type Feature = SpecOutput['implemented_features'][number]; + +interface FeaturesSectionProps { + features: Feature[]; + onChange: (features: Feature[]) => void; +} + +interface FeatureWithId extends Feature { + _id: string; + _locationIds?: string[]; +} + +function generateId(): string { + return crypto.randomUUID(); +} + +function featureToInternal(feature: Feature): FeatureWithId { + return { + ...feature, + _id: generateId(), + _locationIds: feature.file_locations?.map(() => generateId()), + }; +} + +function internalToFeature(internal: FeatureWithId): Feature { + const { _id, _locationIds, ...feature } = internal; + return feature; +} + +interface FeatureCardProps { + feature: FeatureWithId; + index: number; + onChange: (feature: FeatureWithId) => void; + onRemove: () => void; +} + +function FeatureCard({ feature, index, onChange, onRemove }: FeatureCardProps) { + const [isOpen, setIsOpen] = useState(false); + + const handleNameChange = (name: string) => { + onChange({ ...feature, name }); + }; + + const handleDescriptionChange = (description: string) => { + onChange({ ...feature, description }); + }; + + const handleAddLocation = () => { + const locations = feature.file_locations || []; + const locationIds = feature._locationIds || []; + onChange({ + ...feature, + file_locations: [...locations, ''], + _locationIds: [...locationIds, generateId()], + }); + }; + + const handleRemoveLocation = (locId: string) => { + const locationIds = feature._locationIds || []; + const idx = locationIds.indexOf(locId); + if (idx === -1) return; + + const newLocations = feature.file_locations?.filter((_, i) => i !== idx); + const newLocationIds = locationIds.filter((id) => id !== locId); + onChange({ + ...feature, + file_locations: newLocations && newLocations.length > 0 ? newLocations : undefined, + _locationIds: newLocationIds.length > 0 ? newLocationIds : undefined, + }); + }; + + const handleLocationChange = (locId: string, value: string) => { + const locationIds = feature._locationIds || []; + const idx = locationIds.indexOf(locId); + if (idx === -1) return; + + const locations = [...(feature.file_locations || [])]; + locations[idx] = value; + onChange({ ...feature, file_locations: locations }); + }; + + return ( + + +
+ + + +
+ handleNameChange(e.target.value)} + placeholder="Feature name..." + className="font-medium" + /> +
+ + #{index + 1} + + +
+ +
+
+ +