From e57549c06e467b412a3f2ec0575f58ac78db7752 Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 15 Jan 2026 16:20:08 +0100 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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() {
); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx index 268e67be..e2673415 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx @@ -1,10 +1,11 @@ // @ts-nocheck -import { useEffect, useMemo, useState } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { Feature, useAppStore } from '@/store/app-store'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; +import { useShallow } from 'zustand/react/shallow'; /** Uniform badge style for all card badges */ const uniformBadgeClass = @@ -18,7 +19,7 @@ interface CardBadgesProps { * CardBadges - Shows error badges below the card header * Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency */ -export function CardBadges({ feature }: CardBadgesProps) { +export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps) { if (!feature.error) { return null; } @@ -46,14 +47,19 @@ export function CardBadges({ feature }: CardBadgesProps) {
); -} +}); interface PriorityBadgesProps { feature: Feature; } -export function PriorityBadges({ feature }: PriorityBadgesProps) { - const { enableDependencyBlocking, features } = useAppStore(); +export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) { + const { enableDependencyBlocking, features } = useAppStore( + useShallow((state) => ({ + enableDependencyBlocking: state.enableDependencyBlocking, + features: state.features, + })) + ); const [currentTime, setCurrentTime] = useState(() => Date.now()); // Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies) @@ -223,4 +229,4 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) { )} ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx index 237c0a7e..5b2229d8 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx @@ -1,4 +1,5 @@ // @ts-nocheck +import { memo } from 'react'; import { Feature } from '@/store/app-store'; import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react'; @@ -7,7 +8,10 @@ interface CardContentSectionsProps { useWorktrees: boolean; } -export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) { +export const CardContentSections = memo(function CardContentSections({ + feature, + useWorktrees, +}: CardContentSectionsProps) { return ( <> {/* Target Branch Display */} @@ -48,4 +52,4 @@ export function CardContentSections({ feature, useWorktrees }: CardContentSectio })()} ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 73d1dc3a..87a26cdf 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { useState } from 'react'; +import { memo, useState } from 'react'; import { Feature } from '@/store/app-store'; import { cn } from '@/lib/utils'; import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -37,7 +37,7 @@ interface CardHeaderProps { onSpawnTask?: () => void; } -export function CardHeaderSection({ +export const CardHeaderSection = memo(function CardHeaderSection({ feature, isDraggable, isCurrentAutoTask, @@ -378,4 +378,4 @@ export function CardHeaderSection({ /> ); -} +}); 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 a6f1753f..31863fb5 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 @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'; import { Card, CardContent } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Feature, useAppStore } from '@/store/app-store'; +import { useShallow } from 'zustand/react/shallow'; import { CardBadges, PriorityBadges } from './card-badges'; import { CardHeaderSection } from './card-header'; import { CardContentSections } from './card-content-sections'; @@ -61,6 +62,7 @@ interface KanbanCardProps { cardBorderEnabled?: boolean; cardBorderOpacity?: number; isOverlay?: boolean; + reduceEffects?: boolean; // Selection mode props isSelectionMode?: boolean; isSelected?: boolean; @@ -94,12 +96,18 @@ export const KanbanCard = memo(function KanbanCard({ cardBorderEnabled = true, cardBorderOpacity = 100, isOverlay, + reduceEffects = false, isSelectionMode = false, isSelected = false, onToggleSelect, selectionTarget = null, }: KanbanCardProps) { - const { useWorktrees, currentProject } = useAppStore(); + const { useWorktrees, currentProject } = useAppStore( + useShallow((state) => ({ + useWorktrees: state.useWorktrees, + currentProject: state.currentProject, + })) + ); const [isLifted, setIsLifted] = useState(false); useLayoutEffect(() => { @@ -140,9 +148,12 @@ export const KanbanCard = memo(function KanbanCard({ const hasError = feature.error && !isCurrentAutoTask; const innerCardClasses = cn( - 'kanban-card-content h-full relative shadow-sm', + 'kanban-card-content h-full relative', + reduceEffects ? 'shadow-none' : 'shadow-sm', 'transition-all duration-200 ease-out', - isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent', + isInteractive && + !reduceEffects && + 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent', !glassmorphism && 'backdrop-blur-[0px]!', !isCurrentAutoTask && cardBorderEnabled && diff --git a/apps/ui/src/components/views/board-view/components/kanban-column.tsx b/apps/ui/src/components/views/board-view/components/kanban-column.tsx index 4a1b62dd..1fc1029b 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-column.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-column.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { cn } from '@/lib/utils'; -import type { ReactNode } from 'react'; +import type { CSSProperties, ReactNode, Ref, UIEvent } from 'react'; interface KanbanColumnProps { id: string; @@ -17,6 +17,11 @@ interface KanbanColumnProps { hideScrollbar?: boolean; /** Custom width in pixels. If not provided, defaults to 288px (w-72) */ width?: number; + contentRef?: Ref; + onScroll?: (event: UIEvent) => void; + contentClassName?: string; + contentStyle?: CSSProperties; + disableItemSpacing?: boolean; } export const KanbanColumn = memo(function KanbanColumn({ @@ -31,6 +36,11 @@ export const KanbanColumn = memo(function KanbanColumn({ showBorder = true, hideScrollbar = false, width, + contentRef, + onScroll, + contentClassName, + contentStyle, + disableItemSpacing = false, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id }); @@ -78,14 +88,19 @@ export const KanbanColumn = memo(function KanbanColumn({ {/* Column Content */}
{children}
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts index 1d831f4b..8a34bdea 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts @@ -1,7 +1,11 @@ // @ts-nocheck import { useMemo, useCallback } from 'react'; import { Feature, useAppStore } from '@/store/app-store'; -import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver'; +import { + createFeatureMap, + getBlockingDependenciesFromMap, + resolveDependencies, +} from '@automaker/dependency-resolver'; type ColumnId = Feature['status']; @@ -32,6 +36,8 @@ export function useBoardColumnFeatures({ verified: [], completed: [], // Completed features are shown in the archive modal, not as a column }; + const featureMap = createFeatureMap(features); + const runningTaskIds = new Set(runningAutoTasks); // Filter features by search query (case-insensitive) const normalizedQuery = searchQuery.toLowerCase().trim(); @@ -55,7 +61,7 @@ export function useBoardColumnFeatures({ filteredFeatures.forEach((f) => { // If feature has a running agent, always show it in "in_progress" - const isRunning = runningAutoTasks.includes(f.id); + const isRunning = runningTaskIds.has(f.id); // Check if feature matches the current worktree by branchName // Features without branchName are considered unassigned (show only on primary worktree) @@ -151,7 +157,6 @@ export function useBoardColumnFeatures({ const { orderedFeatures } = resolveDependencies(map.backlog); // Get all features to check blocking dependencies against - const allFeatures = features; const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking; // Sort blocked features to the end of the backlog @@ -161,7 +166,7 @@ export function useBoardColumnFeatures({ const blocked: Feature[] = []; for (const f of orderedFeatures) { - if (getBlockingDependencies(f, allFeatures).length > 0) { + if (getBlockingDependenciesFromMap(f, featureMap).length > 0) { blocked.push(f); } else { unblocked.push(f); diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 6ace0e76..4b642ece 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode, UIEvent, RefObject } from 'react'; import { DndContext, DragOverlay } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Button } from '@/components/ui/button'; @@ -64,6 +65,199 @@ interface KanbanBoardProps { className?: string; } +const KANBAN_VIRTUALIZATION_THRESHOLD = 40; +const KANBAN_CARD_ESTIMATED_HEIGHT_PX = 220; +const KANBAN_CARD_GAP_PX = 10; +const KANBAN_OVERSCAN_COUNT = 6; +const VIRTUALIZATION_MEASURE_EPSILON_PX = 1; +const REDUCED_CARD_OPACITY_PERCENT = 85; + +type VirtualListItem = { id: string }; + +interface VirtualListState { + contentRef: RefObject; + onScroll: (event: UIEvent) => void; + itemIds: string[]; + visibleItems: Item[]; + totalHeight: number; + offsetTop: number; + startIndex: number; + shouldVirtualize: boolean; + registerItem: (id: string) => (node: HTMLDivElement | null) => void; +} + +interface VirtualizedListProps { + items: Item[]; + isDragging: boolean; + estimatedItemHeight: number; + itemGap: number; + overscan: number; + virtualizationThreshold: number; + children: (state: VirtualListState) => ReactNode; +} + +function findIndexForOffset(itemEnds: number[], offset: number): number { + let low = 0; + let high = itemEnds.length - 1; + let result = itemEnds.length; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (itemEnds[mid] >= offset) { + result = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + return Math.min(result, itemEnds.length - 1); +} + +// Virtualize long columns while keeping full DOM during drag interactions. +function VirtualizedList({ + items, + isDragging, + estimatedItemHeight, + itemGap, + overscan, + virtualizationThreshold, + children, +}: VirtualizedListProps) { + const contentRef = useRef(null); + const measurementsRef = useRef>(new Map()); + const scrollRafRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + const [measureVersion, setMeasureVersion] = useState(0); + + const itemIds = useMemo(() => items.map((item) => item.id), [items]); + const shouldVirtualize = !isDragging && items.length >= virtualizationThreshold; + + const itemSizes = useMemo(() => { + return items.map((item) => { + const measured = measurementsRef.current.get(item.id); + const resolvedHeight = measured ?? estimatedItemHeight; + return resolvedHeight + itemGap; + }); + }, [items, estimatedItemHeight, itemGap, measureVersion]); + + const itemStarts = useMemo(() => { + let offset = 0; + return itemSizes.map((size) => { + const start = offset; + offset += size; + return start; + }); + }, [itemSizes]); + + const itemEnds = useMemo(() => { + return itemStarts.map((start, index) => start + itemSizes[index]); + }, [itemStarts, itemSizes]); + + const totalHeight = itemEnds.length > 0 ? itemEnds[itemEnds.length - 1] : 0; + + const { startIndex, endIndex, offsetTop } = useMemo(() => { + if (!shouldVirtualize || items.length === 0) { + return { startIndex: 0, endIndex: items.length, offsetTop: 0 }; + } + + const firstVisible = findIndexForOffset(itemEnds, scrollTop); + const lastVisible = findIndexForOffset(itemEnds, scrollTop + viewportHeight); + const overscannedStart = Math.max(0, firstVisible - overscan); + const overscannedEnd = Math.min(items.length, lastVisible + overscan + 1); + + return { + startIndex: overscannedStart, + endIndex: overscannedEnd, + offsetTop: itemStarts[overscannedStart] ?? 0, + }; + }, [shouldVirtualize, items.length, itemEnds, itemStarts, overscan, scrollTop, viewportHeight]); + + const visibleItems = shouldVirtualize ? items.slice(startIndex, endIndex) : items; + + const onScroll = useCallback((event: UIEvent) => { + const target = event.currentTarget; + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + scrollRafRef.current = requestAnimationFrame(() => { + setScrollTop(target.scrollTop); + scrollRafRef.current = null; + }); + }, []); + + const registerItem = useCallback( + (id: string) => (node: HTMLDivElement | null) => { + if (!node || !shouldVirtualize) return; + const measuredHeight = node.getBoundingClientRect().height; + const previousHeight = measurementsRef.current.get(id); + if ( + previousHeight === undefined || + Math.abs(previousHeight - measuredHeight) > VIRTUALIZATION_MEASURE_EPSILON_PX + ) { + measurementsRef.current.set(id, measuredHeight); + setMeasureVersion((value) => value + 1); + } + }, + [shouldVirtualize] + ); + + useEffect(() => { + const container = contentRef.current; + if (!container || typeof window === 'undefined') return; + + const updateHeight = () => { + setViewportHeight(container.clientHeight); + }; + + updateHeight(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateHeight); + return () => window.removeEventListener('resize', updateHeight); + } + + const observer = new ResizeObserver(() => updateHeight()); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!shouldVirtualize) return; + const currentIds = new Set(items.map((item) => item.id)); + for (const id of measurementsRef.current.keys()) { + if (!currentIds.has(id)) { + measurementsRef.current.delete(id); + } + } + }, [items, shouldVirtualize]); + + useEffect(() => { + return () => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + }; + }, []); + + return ( + <> + {children({ + contentRef, + onScroll, + itemIds, + visibleItems, + totalHeight, + offsetTop, + startIndex, + shouldVirtualize, + registerItem, + })} + + ); +} + export function KanbanBoard({ sensors, collisionDetectionStrategy, @@ -109,7 +303,7 @@ export function KanbanBoard({ const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]); // Get the keyboard shortcut for adding features - const { keyboardShortcuts } = useAppStore(); + const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); const addFeatureShortcut = keyboardShortcuts.addFeature || 'N'; // Use responsive column widths based on window size @@ -135,213 +329,307 @@ export function KanbanBoard({ {columns.map((column) => { const columnFeatures = getColumnFeatures(column.id as ColumnId); return ( - - {columnFeatures.length > 0 && ( + items={columnFeatures} + isDragging={isDragging} + estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX} + itemGap={KANBAN_CARD_GAP_PX} + overscan={KANBAN_OVERSCAN_COUNT} + virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD} + > + {({ + contentRef, + onScroll, + itemIds, + visibleItems, + totalHeight, + offsetTop, + startIndex, + shouldVirtualize, + registerItem, + }) => ( + + {columnFeatures.length > 0 && ( + + )} + + + ) : column.id === 'backlog' ? ( +
+ + +
+ ) : column.id === 'waiting_approval' ? ( - )} - - - ) : column.id === 'backlog' ? ( -
- - -
- ) : column.id === 'waiting_approval' ? ( - - ) : column.id === 'in_progress' ? ( - - ) : column.isPipelineStep ? ( - - ) : undefined - } - footerAction={ - column.id === 'backlog' ? ( - - ) : undefined - } - > - f.id)} - strategy={verticalListSortingStrategy} - > - {/* Empty state card when column has no features */} - {columnFeatures.length === 0 && !isDragging && ( - - )} - {columnFeatures.map((feature, index) => { - // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0) - let shortcutKey: string | undefined; - if (column.id === 'in_progress' && index < 10) { - shortcutKey = index === 9 ? '0' : String(index + 1); + ) : column.id === 'in_progress' ? ( + + ) : column.isPipelineStep ? ( + + ) : undefined } - return ( - onEdit(feature)} - onDelete={() => onDelete(feature.id)} - onViewOutput={() => onViewOutput(feature)} - onVerify={() => onVerify(feature)} - onResume={() => onResume(feature)} - onForceStop={() => onForceStop(feature)} - onManualVerify={() => onManualVerify(feature)} - onMoveBackToInProgress={() => onMoveBackToInProgress(feature)} - onFollowUp={() => onFollowUp(feature)} - onComplete={() => onComplete(feature)} - onImplement={() => onImplement(feature)} - onViewPlan={() => onViewPlan(feature)} - onApprovePlan={() => onApprovePlan(feature)} - onSpawnTask={() => onSpawnTask?.(feature)} - hasContext={featuresWithContext.has(feature.id)} - isCurrentAutoTask={runningAutoTasks.includes(feature.id)} - shortcutKey={shortcutKey} - opacity={backgroundSettings.cardOpacity} - glassmorphism={backgroundSettings.cardGlassmorphism} - cardBorderEnabled={backgroundSettings.cardBorderEnabled} - cardBorderOpacity={backgroundSettings.cardBorderOpacity} - isSelectionMode={isSelectionMode} - selectionTarget={selectionTarget} - isSelected={selectedFeatureIds.has(feature.id)} - onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} - /> - ); - })} - -
+ footerAction={ + column.id === 'backlog' ? ( + + ) : undefined + } + > + {(() => { + const reduceEffects = shouldVirtualize; + const effectiveCardOpacity = reduceEffects + ? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT) + : backgroundSettings.cardOpacity; + const effectiveGlassmorphism = + backgroundSettings.cardGlassmorphism && !reduceEffects; + + return ( + + {/* Empty state card when column has no features */} + {columnFeatures.length === 0 && !isDragging && ( + + )} + {shouldVirtualize ? ( +
+
+ {visibleItems.map((feature, index) => { + const absoluteIndex = startIndex + index; + let shortcutKey: string | undefined; + if (column.id === 'in_progress' && absoluteIndex < 10) { + shortcutKey = + absoluteIndex === 9 ? '0' : String(absoluteIndex + 1); + } + return ( +
+ onEdit(feature)} + onDelete={() => onDelete(feature.id)} + onViewOutput={() => onViewOutput(feature)} + onVerify={() => onVerify(feature)} + onResume={() => onResume(feature)} + onForceStop={() => onForceStop(feature)} + onManualVerify={() => onManualVerify(feature)} + onMoveBackToInProgress={() => + onMoveBackToInProgress(feature) + } + onFollowUp={() => onFollowUp(feature)} + onComplete={() => onComplete(feature)} + onImplement={() => onImplement(feature)} + onViewPlan={() => onViewPlan(feature)} + onApprovePlan={() => onApprovePlan(feature)} + onSpawnTask={() => onSpawnTask?.(feature)} + hasContext={featuresWithContext.has(feature.id)} + isCurrentAutoTask={runningAutoTasks.includes(feature.id)} + shortcutKey={shortcutKey} + opacity={effectiveCardOpacity} + glassmorphism={effectiveGlassmorphism} + cardBorderEnabled={backgroundSettings.cardBorderEnabled} + cardBorderOpacity={backgroundSettings.cardBorderOpacity} + reduceEffects={reduceEffects} + isSelectionMode={isSelectionMode} + selectionTarget={selectionTarget} + isSelected={selectedFeatureIds.has(feature.id)} + onToggleSelect={() => + onToggleFeatureSelection?.(feature.id) + } + /> +
+ ); + })} +
+
+ ) : ( + columnFeatures.map((feature, index) => { + let shortcutKey: string | undefined; + if (column.id === 'in_progress' && index < 10) { + shortcutKey = index === 9 ? '0' : String(index + 1); + } + return ( + onEdit(feature)} + onDelete={() => onDelete(feature.id)} + onViewOutput={() => onViewOutput(feature)} + onVerify={() => onVerify(feature)} + onResume={() => onResume(feature)} + onForceStop={() => onForceStop(feature)} + onManualVerify={() => onManualVerify(feature)} + onMoveBackToInProgress={() => onMoveBackToInProgress(feature)} + onFollowUp={() => onFollowUp(feature)} + onComplete={() => onComplete(feature)} + onImplement={() => onImplement(feature)} + onViewPlan={() => onViewPlan(feature)} + onApprovePlan={() => onApprovePlan(feature)} + onSpawnTask={() => onSpawnTask?.(feature)} + hasContext={featuresWithContext.has(feature.id)} + isCurrentAutoTask={runningAutoTasks.includes(feature.id)} + shortcutKey={shortcutKey} + opacity={effectiveCardOpacity} + glassmorphism={effectiveGlassmorphism} + cardBorderEnabled={backgroundSettings.cardBorderEnabled} + cardBorderOpacity={backgroundSettings.cardBorderOpacity} + reduceEffects={reduceEffects} + isSelectionMode={isSelectionMode} + selectionTarget={selectionTarget} + isSelected={selectedFeatureIds.has(feature.id)} + onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} + /> + ); + }) + )} +
+ ); + })()} +
+ )} + ); })} diff --git a/apps/ui/src/components/views/chat-history.tsx b/apps/ui/src/components/views/chat-history.tsx index e6939361..eed0b062 100644 --- a/apps/ui/src/components/views/chat-history.tsx +++ b/apps/ui/src/components/views/chat-history.tsx @@ -1,5 +1,7 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { UIEvent } from 'react'; import { useAppStore } from '@/store/app-store'; +import { useShallow } from 'zustand/react/shallow'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -22,6 +24,10 @@ import { } from '@/components/ui/dropdown-menu'; import { Badge } from '@/components/ui/badge'; +const CHAT_SESSION_ROW_HEIGHT_PX = 84; +const CHAT_SESSION_OVERSCAN_COUNT = 6; +const CHAT_SESSION_LIST_PADDING_PX = 8; + export function ChatHistory() { const { chatSessions, @@ -34,29 +40,117 @@ export function ChatHistory() { unarchiveChatSession, deleteChatSession, setChatHistoryOpen, - } = useAppStore(); + } = useAppStore( + useShallow((state) => ({ + chatSessions: state.chatSessions, + currentProject: state.currentProject, + currentChatSession: state.currentChatSession, + chatHistoryOpen: state.chatHistoryOpen, + createChatSession: state.createChatSession, + setCurrentChatSession: state.setCurrentChatSession, + archiveChatSession: state.archiveChatSession, + unarchiveChatSession: state.unarchiveChatSession, + deleteChatSession: state.deleteChatSession, + setChatHistoryOpen: state.setChatHistoryOpen, + })) + ); const [searchQuery, setSearchQuery] = useState(''); const [showArchived, setShowArchived] = useState(false); + const listRef = useRef(null); + const scrollRafRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); - if (!currentProject) { - return null; - } + const normalizedQuery = searchQuery.trim().toLowerCase(); + const currentProjectId = currentProject?.id; // Filter sessions for current project - const projectSessions = chatSessions.filter((session) => session.projectId === currentProject.id); + const projectSessions = useMemo(() => { + if (!currentProjectId) return []; + return chatSessions.filter((session) => session.projectId === currentProjectId); + }, [chatSessions, currentProjectId]); // Filter by search query and archived status - const filteredSessions = projectSessions.filter((session) => { - const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesArchivedStatus = showArchived ? session.archived : !session.archived; - return matchesSearch && matchesArchivedStatus; - }); + const filteredSessions = useMemo(() => { + return projectSessions.filter((session) => { + const matchesSearch = session.title.toLowerCase().includes(normalizedQuery); + const matchesArchivedStatus = showArchived ? session.archived : !session.archived; + return matchesSearch && matchesArchivedStatus; + }); + }, [projectSessions, normalizedQuery, showArchived]); // Sort by most recently updated - const sortedSessions = filteredSessions.sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + const sortedSessions = useMemo(() => { + return [...filteredSessions].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + }, [filteredSessions]); + + const totalHeight = + sortedSessions.length * CHAT_SESSION_ROW_HEIGHT_PX + CHAT_SESSION_LIST_PADDING_PX * 2; + const startIndex = Math.max( + 0, + Math.floor(scrollTop / CHAT_SESSION_ROW_HEIGHT_PX) - CHAT_SESSION_OVERSCAN_COUNT ); + const endIndex = Math.min( + sortedSessions.length, + Math.ceil((scrollTop + viewportHeight) / CHAT_SESSION_ROW_HEIGHT_PX) + + CHAT_SESSION_OVERSCAN_COUNT + ); + const offsetTop = startIndex * CHAT_SESSION_ROW_HEIGHT_PX; + const visibleSessions = sortedSessions.slice(startIndex, endIndex); + + const handleScroll = useCallback((event: UIEvent) => { + const target = event.currentTarget; + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + scrollRafRef.current = requestAnimationFrame(() => { + setScrollTop(target.scrollTop); + scrollRafRef.current = null; + }); + }, []); + + useEffect(() => { + const container = listRef.current; + if (!container || typeof window === 'undefined') return; + + const updateHeight = () => { + setViewportHeight(container.clientHeight); + }; + + updateHeight(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateHeight); + return () => window.removeEventListener('resize', updateHeight); + } + + const observer = new ResizeObserver(() => updateHeight()); + observer.observe(container); + return () => observer.disconnect(); + }, [chatHistoryOpen]); + + useEffect(() => { + if (!chatHistoryOpen) return; + setScrollTop(0); + if (listRef.current) { + listRef.current.scrollTop = 0; + } + }, [chatHistoryOpen, normalizedQuery, showArchived, currentProjectId]); + + useEffect(() => { + return () => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + }; + }, []); + + if (!currentProjectId) { + return null; + } const handleCreateNewChat = () => { createChatSession(); @@ -151,7 +245,11 @@ export function ChatHistory() { {/* Chat Sessions List */} -
+
{sortedSessions.length === 0 ? (
{searchQuery ? ( @@ -163,60 +261,75 @@ export function ChatHistory() { )}
) : ( -
- {sortedSessions.map((session) => ( -
handleSelectSession(session)} - > -
-

{session.title}

-

- {session.messages.length} messages -

-

- {new Date(session.updatedAt).toLocaleDateString()} -

-
+
+
+ {visibleSessions.map((session) => ( +
handleSelectSession(session)} + > +
+

{session.title}

+

+ {session.messages.length} messages +

+

+ {new Date(session.updatedAt).toLocaleDateString()} +

+
-
- - - - - - {session.archived ? ( +
+ + + + + + {session.archived ? ( + handleUnarchiveSession(session.id, e)} + > + + Unarchive + + ) : ( + handleArchiveSession(session.id, e)} + > + + Archive + + )} + handleUnarchiveSession(session.id, e)} + onClick={(e) => handleDeleteSession(session.id, e)} + className="text-destructive" > - - Unarchive + + Delete - ) : ( - handleArchiveSession(session.id, e)}> - - Archive - - )} - - handleDeleteSession(session.id, e)} - className="text-destructive" - > - - Delete - - - + + +
-
- ))} + ))} +
)}
diff --git a/apps/ui/src/components/views/graph-view-page.tsx b/apps/ui/src/components/views/graph-view-page.tsx index 47acf313..e3899297 100644 --- a/apps/ui/src/components/views/graph-view-page.tsx +++ b/apps/ui/src/components/views/graph-view-page.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import { useState, useCallback, useMemo, useEffect } from 'react'; import { useAppStore, Feature } from '@/store/app-store'; +import { useShallow } from 'zustand/react/shallow'; import { GraphView } from './graph-view'; import { EditFeatureDialog, @@ -40,7 +41,20 @@ export function GraphViewPage() { addFeatureUseSelectedWorktreeBranch, planUseSelectedWorktreeBranch, setPlanUseSelectedWorktreeBranch, - } = useAppStore(); + } = useAppStore( + useShallow((state) => ({ + currentProject: state.currentProject, + updateFeature: state.updateFeature, + getCurrentWorktree: state.getCurrentWorktree, + getWorktrees: state.getWorktrees, + setWorktrees: state.setWorktrees, + setCurrentWorktree: state.setCurrentWorktree, + defaultSkipTests: state.defaultSkipTests, + addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch, + planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch, + setPlanUseSelectedWorktreeBranch: state.setPlanUseSelectedWorktreeBranch, + })) + ); // Ensure worktrees are loaded when landing directly on graph view useWorktrees({ projectPath: currentProject?.path ?? '' }); diff --git a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx index 8ad385b9..44cac85c 100644 --- a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx +++ b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx @@ -4,6 +4,7 @@ import type { EdgeProps } from '@xyflow/react'; import { cn } from '@/lib/utils'; import { Feature } from '@/store/app-store'; import { Trash2 } from 'lucide-react'; +import { GRAPH_RENDER_MODE_COMPACT, type GraphRenderMode } from '../constants'; export interface DependencyEdgeData { sourceStatus: Feature['status']; @@ -11,6 +12,7 @@ export interface DependencyEdgeData { isHighlighted?: boolean; isDimmed?: boolean; onDeleteDependency?: (sourceId: string, targetId: string) => void; + renderMode?: GraphRenderMode; } const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => { @@ -61,6 +63,7 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { const isHighlighted = edgeData?.isHighlighted ?? false; const isDimmed = edgeData?.isDimmed ?? false; + const isCompact = edgeData?.renderMode === GRAPH_RENDER_MODE_COMPACT; const edgeColor = isHighlighted ? 'var(--brand-500)' @@ -86,6 +89,51 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { } }; + if (isCompact) { + return ( + <> + + {selected && edgeData?.onDeleteDependency && ( + +
+ +
+
+ )} + + ); + } + return ( <> {/* Invisible wider path for hover detection */} diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx index 020b1914..16cf6817 100644 --- a/apps/ui/src/components/views/graph-view/components/task-node.tsx +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -18,6 +18,7 @@ import { Trash2, } from 'lucide-react'; import { TaskNodeData } from '../hooks/use-graph-nodes'; +import { GRAPH_RENDER_MODE_COMPACT } from '../constants'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -109,9 +110,11 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps // Background/theme settings with defaults const cardOpacity = data.cardOpacity ?? 100; - const glassmorphism = data.cardGlassmorphism ?? true; + const shouldUseGlassmorphism = data.cardGlassmorphism ?? true; const cardBorderEnabled = data.cardBorderEnabled ?? true; const cardBorderOpacity = data.cardBorderOpacity ?? 100; + const isCompact = data.renderMode === GRAPH_RENDER_MODE_COMPACT; + const glassmorphism = shouldUseGlassmorphism && !isCompact; // Get the border color based on status and error state const borderColor = data.error @@ -129,6 +132,99 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps // Get computed border style const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor); + if (isCompact) { + return ( + <> + + +
+
+
+ + {config.label} + {priorityConf && ( + + {data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'} + + )} +
+
+

+ {data.title || data.description} +

+ {data.title && data.description && ( +

+ {data.description} +

+ )} + {data.isRunning && ( +
+ + Running +
+ )} + {isStopped && ( +
+ + Paused +
+ )} +
+
+ + + + ); + } + return ( <> {/* Target handle (left side - receives dependencies) */} diff --git a/apps/ui/src/components/views/graph-view/constants.ts b/apps/ui/src/components/views/graph-view/constants.ts new file mode 100644 index 00000000..d75b6ea8 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/constants.ts @@ -0,0 +1,7 @@ +export const GRAPH_RENDER_MODE_FULL = 'full'; +export const GRAPH_RENDER_MODE_COMPACT = 'compact'; + +export type GraphRenderMode = typeof GRAPH_RENDER_MODE_FULL | typeof GRAPH_RENDER_MODE_COMPACT; + +export const GRAPH_LARGE_NODE_COUNT = 150; +export const GRAPH_LARGE_EDGE_COUNT = 300; diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index f14f3120..1286a745 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useEffect, useRef } from 'react'; +import { useCallback, useState, useEffect, useMemo, useRef } from 'react'; import { ReactFlow, Background, @@ -39,6 +39,12 @@ import { useDebounceValue } from 'usehooks-ts'; import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover'; +import { + GRAPH_LARGE_EDGE_COUNT, + GRAPH_LARGE_NODE_COUNT, + GRAPH_RENDER_MODE_COMPACT, + GRAPH_RENDER_MODE_FULL, +} from './constants'; // Define custom node and edge types - using any to avoid React Flow's strict typing // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -198,6 +204,17 @@ function GraphCanvasInner({ // Calculate filter results const filterResult = useGraphFilter(features, filterState, runningAutoTasks); + const estimatedEdgeCount = useMemo(() => { + return features.reduce((total, feature) => { + const deps = feature.dependencies as string[] | undefined; + return total + (deps?.length ?? 0); + }, 0); + }, [features]); + + const isLargeGraph = + features.length >= GRAPH_LARGE_NODE_COUNT || estimatedEdgeCount >= GRAPH_LARGE_EDGE_COUNT; + const renderMode = isLargeGraph ? GRAPH_RENDER_MODE_COMPACT : GRAPH_RENDER_MODE_FULL; + // Transform features to nodes and edges with filter results const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({ features, @@ -205,6 +222,8 @@ function GraphCanvasInner({ filterResult, actionCallbacks: nodeActionCallbacks, backgroundSettings, + renderMode, + enableEdgeAnimations: !isLargeGraph, }); // Apply layout @@ -457,6 +476,8 @@ function GraphCanvasInner({ } }, []); + const shouldRenderVisibleOnly = isLargeGraph; + return (
diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index 245894ab..e84bb1d5 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -51,7 +51,7 @@ export function GraphView({ planUseSelectedWorktreeBranch, onPlanUseSelectedWorktreeBranchChange, }: GraphViewProps) { - const { currentProject } = useAppStore(); + const currentProject = useAppStore((state) => state.currentProject); // Use the same background hook as the board view const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject }); diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts index 8349bff6..e769e4e3 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts @@ -54,16 +54,40 @@ function getAncestors( /** * Traverses down to find all descendants (features that depend on this one) */ -function getDescendants(featureId: string, features: Feature[], visited: Set): void { +function getDescendants( + featureId: string, + dependentsMap: Map, + visited: Set +): void { if (visited.has(featureId)) return; visited.add(featureId); + const dependents = dependentsMap.get(featureId); + if (!dependents || dependents.length === 0) return; + + for (const dependentId of dependents) { + getDescendants(dependentId, dependentsMap, visited); + } +} + +function buildDependentsMap(features: Feature[]): Map { + const dependentsMap = new Map(); + for (const feature of features) { const deps = feature.dependencies as string[] | undefined; - if (deps?.includes(featureId)) { - getDescendants(feature.id, features, visited); + if (!deps || deps.length === 0) continue; + + for (const depId of deps) { + const existing = dependentsMap.get(depId); + if (existing) { + existing.push(feature.id); + } else { + dependentsMap.set(depId, [feature.id]); + } } } + + return dependentsMap; } /** @@ -91,9 +115,9 @@ function getHighlightedEdges(highlightedNodeIds: Set, features: Feature[ * Gets the effective status of a feature (accounting for running state) * Treats completed (archived) as verified */ -function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue { +function getEffectiveStatus(feature: Feature, runningTaskIds: Set): StatusFilterValue { if (feature.status === 'in_progress') { - return runningAutoTasks.includes(feature.id) ? 'running' : 'paused'; + return runningTaskIds.has(feature.id) ? 'running' : 'paused'; } // Treat completed (archived) as verified if (feature.status === 'completed') { @@ -119,6 +143,7 @@ export function useGraphFilter( ).sort(); const normalizedQuery = searchQuery.toLowerCase().trim(); + const runningTaskIds = new Set(runningAutoTasks); const hasSearchQuery = normalizedQuery.length > 0; const hasCategoryFilter = selectedCategories.length > 0; const hasStatusFilter = selectedStatuses.length > 0; @@ -139,6 +164,7 @@ export function useGraphFilter( // Find directly matched nodes const matchedNodeIds = new Set(); const featureMap = new Map(features.map((f) => [f.id, f])); + const dependentsMap = buildDependentsMap(features); for (const feature of features) { let matchesSearch = true; @@ -159,7 +185,7 @@ export function useGraphFilter( // Check status match if (hasStatusFilter) { - const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks); + const effectiveStatus = getEffectiveStatus(feature, runningTaskIds); matchesStatus = selectedStatuses.includes(effectiveStatus); } @@ -190,7 +216,7 @@ export function useGraphFilter( getAncestors(id, featureMap, highlightedNodeIds); // Add all descendants (dependents) - getDescendants(id, features, highlightedNodeIds); + getDescendants(id, dependentsMap, highlightedNodeIds); } // Get edges in the highlighted path diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts index 3e9e41e0..3b902611 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import { Node, Edge } from '@xyflow/react'; import { Feature } from '@/store/app-store'; -import { getBlockingDependencies } from '@automaker/dependency-resolver'; +import { createFeatureMap, getBlockingDependenciesFromMap } from '@automaker/dependency-resolver'; +import { GRAPH_RENDER_MODE_FULL, type GraphRenderMode } from '../constants'; import { GraphFilterResult } from './use-graph-filter'; export interface TaskNodeData extends Feature { @@ -31,6 +32,7 @@ export interface TaskNodeData extends Feature { onResumeTask?: () => void; onSpawnTask?: () => void; onDeleteTask?: () => void; + renderMode?: GraphRenderMode; } export type TaskNode = Node; @@ -40,6 +42,7 @@ export type DependencyEdge = Edge<{ isHighlighted?: boolean; isDimmed?: boolean; onDeleteDependency?: (sourceId: string, targetId: string) => void; + renderMode?: GraphRenderMode; }>; export interface NodeActionCallbacks { @@ -66,6 +69,8 @@ interface UseGraphNodesProps { filterResult?: GraphFilterResult; actionCallbacks?: NodeActionCallbacks; backgroundSettings?: BackgroundSettings; + renderMode?: GraphRenderMode; + enableEdgeAnimations?: boolean; } /** @@ -78,14 +83,14 @@ export function useGraphNodes({ filterResult, actionCallbacks, backgroundSettings, + renderMode = GRAPH_RENDER_MODE_FULL, + enableEdgeAnimations = true, }: UseGraphNodesProps) { const { nodes, edges } = useMemo(() => { const nodeList: TaskNode[] = []; const edgeList: DependencyEdge[] = []; - const featureMap = new Map(); - - // Create feature map for quick lookups - features.forEach((f) => featureMap.set(f.id, f)); + const featureMap = createFeatureMap(features); + const runningTaskIds = new Set(runningAutoTasks); // Extract filter state const hasActiveFilter = filterResult?.hasActiveFilter ?? false; @@ -95,8 +100,8 @@ export function useGraphNodes({ // Create nodes features.forEach((feature) => { - const isRunning = runningAutoTasks.includes(feature.id); - const blockingDeps = getBlockingDependencies(feature, features); + const isRunning = runningTaskIds.has(feature.id); + const blockingDeps = getBlockingDependenciesFromMap(feature, featureMap); // Calculate filter highlight states const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id); @@ -121,6 +126,7 @@ export function useGraphNodes({ cardGlassmorphism: backgroundSettings?.cardGlassmorphism, cardBorderEnabled: backgroundSettings?.cardBorderEnabled, cardBorderOpacity: backgroundSettings?.cardBorderOpacity, + renderMode, // Action callbacks (bound to this feature's ID) onViewLogs: actionCallbacks?.onViewLogs ? () => actionCallbacks.onViewLogs!(feature.id) @@ -166,13 +172,14 @@ export function useGraphNodes({ source: depId, target: feature.id, type: 'dependency', - animated: isRunning || runningAutoTasks.includes(depId), + animated: enableEdgeAnimations && (isRunning || runningTaskIds.has(depId)), data: { sourceStatus: sourceFeature.status, targetStatus: feature.status, isHighlighted: edgeIsHighlighted, isDimmed: edgeIsDimmed, onDeleteDependency: actionCallbacks?.onDeleteDependency, + renderMode, }, }; edgeList.push(edge); 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 83e05e9e..f56e9a64 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 @@ -4,6 +4,7 @@ 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 { ProviderToggle } from './provider-toggle'; import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries'; import { queryKeys } from '@/lib/query-keys'; import type { CliStatus as SharedCliStatus } from '../shared/types'; diff --git a/apps/ui/src/hooks/queries/use-features.ts b/apps/ui/src/hooks/queries/use-features.ts index 89a67987..78db6101 100644 --- a/apps/ui/src/hooks/queries/use-features.ts +++ b/apps/ui/src/hooks/queries/use-features.ts @@ -12,6 +12,9 @@ import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; import type { Feature } from '@/store/app-store'; +const FEATURES_REFETCH_ON_FOCUS = false; +const FEATURES_REFETCH_ON_RECONNECT = false; + /** * Fetch all features for a project * @@ -37,6 +40,8 @@ export function useFeatures(projectPath: string | undefined) { }, enabled: !!projectPath, staleTime: STALE_TIMES.FEATURES, + refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, + refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, }); } @@ -75,6 +80,8 @@ export function useFeature( enabled: !!projectPath && !!featureId && enabled, staleTime: STALE_TIMES.FEATURES, refetchInterval: pollingInterval, + refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, + refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, }); } @@ -123,5 +130,7 @@ export function useAgentOutput( } return false; }, + refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, + refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, }); } diff --git a/apps/ui/src/hooks/queries/use-running-agents.ts b/apps/ui/src/hooks/queries/use-running-agents.ts index a661d9c3..75002226 100644 --- a/apps/ui/src/hooks/queries/use-running-agents.ts +++ b/apps/ui/src/hooks/queries/use-running-agents.ts @@ -10,6 +10,9 @@ import { getElectronAPI, type RunningAgent } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; +const RUNNING_AGENTS_REFETCH_ON_FOCUS = false; +const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false; + interface RunningAgentsResult { agents: RunningAgent[]; count: number; @@ -43,6 +46,8 @@ export function useRunningAgents() { staleTime: STALE_TIMES.RUNNING_AGENTS, // Note: Don't use refetchInterval here - rely on WebSocket invalidation // for real-time updates instead of polling + refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS, + refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT, }); } diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index 38de9bb8..21f0267d 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -13,6 +13,8 @@ import type { ClaudeUsage, CodexUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; +const USAGE_REFETCH_ON_FOCUS = false; +const USAGE_REFETCH_ON_RECONNECT = false; /** * Fetch Claude API usage data @@ -42,6 +44,8 @@ export function useClaudeUsage(enabled = true) { refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, // Keep previous data while refetching placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } @@ -73,5 +77,7 @@ export function useCodexUsage(enabled = true) { refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, // Keep previous data while refetching placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts index 9a7eefec..551894ef 100644 --- a/apps/ui/src/hooks/queries/use-worktrees.ts +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -9,6 +9,9 @@ import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; +const WORKTREE_REFETCH_ON_FOCUS = false; +const WORKTREE_REFETCH_ON_RECONNECT = false; + interface WorktreeInfo { path: string; branch: string; @@ -59,6 +62,8 @@ export function useWorktrees(projectPath: string | undefined, includeDetails = t }, enabled: !!projectPath, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -83,6 +88,8 @@ export function useWorktreeInfo(projectPath: string | undefined, featureId: stri }, enabled: !!projectPath && !!featureId, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -107,6 +114,8 @@ export function useWorktreeStatus(projectPath: string | undefined, featureId: st }, enabled: !!projectPath && !!featureId, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -134,6 +143,8 @@ export function useWorktreeDiffs(projectPath: string | undefined, featureId: str }, enabled: !!projectPath && !!featureId, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -203,6 +214,8 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem }, enabled: !!worktreePath, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -229,6 +242,8 @@ export function useWorktreeInitScript(projectPath: string | undefined) { }, enabled: !!projectPath, staleTime: STALE_TIMES.SETTINGS, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -249,5 +264,7 @@ export function useAvailableEditors() { return result.editors ?? []; }, staleTime: STALE_TIMES.CLI_STATUS, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 8bb286eb..1660e048 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -40,6 +40,7 @@ import { useIsCompact } from '@/hooks/use-media-query'; import type { Project } from '@/lib/electron'; const logger = createLogger('RootLayout'); +const SHOW_QUERY_DEVTOOLS = import.meta.env.DEV; const SERVER_READY_MAX_ATTEMPTS = 8; const SERVER_READY_BACKOFF_BASE_MS = 250; const SERVER_READY_MAX_DELAY_MS = 1500; @@ -899,7 +900,9 @@ function RootLayout() { - + {SHOW_QUERY_DEVTOOLS ? ( + + ) : null} ); } diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index a8a6e53a..3e2ae46d 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -1120,3 +1120,8 @@ animation: none; } } + +.perf-contain { + contain: layout paint; + content-visibility: auto; +} diff --git a/libs/dependency-resolver/src/index.ts b/libs/dependency-resolver/src/index.ts index 63fd22e4..fcae1258 100644 --- a/libs/dependency-resolver/src/index.ts +++ b/libs/dependency-resolver/src/index.ts @@ -7,6 +7,8 @@ export { resolveDependencies, areDependenciesSatisfied, getBlockingDependencies, + createFeatureMap, + getBlockingDependenciesFromMap, wouldCreateCircularDependency, dependencyExists, getAncestors, diff --git a/libs/dependency-resolver/src/resolver.ts b/libs/dependency-resolver/src/resolver.ts index 145617f4..02c87c26 100644 --- a/libs/dependency-resolver/src/resolver.ts +++ b/libs/dependency-resolver/src/resolver.ts @@ -229,6 +229,49 @@ export function getBlockingDependencies(feature: Feature, allFeatures: Feature[] }); } +/** + * Builds a lookup map for features by id. + * + * @param features - Features to index + * @returns Map keyed by feature id + */ +export function createFeatureMap(features: Feature[]): Map { + const featureMap = new Map(); + for (const feature of features) { + if (feature?.id) { + featureMap.set(feature.id, feature); + } + } + return featureMap; +} + +/** + * Gets the blocking dependencies using a precomputed feature map. + * + * @param feature - Feature to check + * @param featureMap - Map of all features by id + * @returns Array of feature IDs that are blocking this feature + */ +export function getBlockingDependenciesFromMap( + feature: Feature, + featureMap: Map +): string[] { + const dependencies = feature.dependencies; + if (!dependencies || dependencies.length === 0) { + return []; + } + + const blockingDependencies: string[] = []; + for (const depId of dependencies) { + const dep = featureMap.get(depId); + if (dep && dep.status !== 'completed' && dep.status !== 'verified') { + blockingDependencies.push(depId); + } + } + + return blockingDependencies; +} + /** * Checks if adding a dependency from sourceId to targetId would create a circular dependency. * When we say "targetId depends on sourceId", we add sourceId to targetId.dependencies. diff --git a/libs/dependency-resolver/tests/resolver.test.ts b/libs/dependency-resolver/tests/resolver.test.ts index 5f246b2a..7f6726f8 100644 --- a/libs/dependency-resolver/tests/resolver.test.ts +++ b/libs/dependency-resolver/tests/resolver.test.ts @@ -3,6 +3,8 @@ import { resolveDependencies, areDependenciesSatisfied, getBlockingDependencies, + createFeatureMap, + getBlockingDependenciesFromMap, wouldCreateCircularDependency, dependencyExists, } from '../src/resolver'; @@ -351,6 +353,21 @@ describe('resolver.ts', () => { }); }); + describe('getBlockingDependenciesFromMap', () => { + it('should match getBlockingDependencies when using a feature map', () => { + const dep1 = createFeature('Dep1', { status: 'pending' }); + const dep2 = createFeature('Dep2', { status: 'completed' }); + const dep3 = createFeature('Dep3', { status: 'running' }); + const feature = createFeature('A', { dependencies: ['Dep1', 'Dep2', 'Dep3'] }); + const allFeatures = [dep1, dep2, dep3, feature]; + const featureMap = createFeatureMap(allFeatures); + + expect(getBlockingDependenciesFromMap(feature, featureMap)).toEqual( + getBlockingDependencies(feature, allFeatures) + ); + }); + }); + describe('wouldCreateCircularDependency', () => { it('should return false for features with no existing dependencies', () => { const features = [createFeature('A'), createFeature('B')]; From 2fac2ca4bbac7240d9b7beb1d80cfbe9c419115f Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Mon, 19 Jan 2026 19:58:10 +0530 Subject: [PATCH 16/21] Fix opencode auth error mapping and perf containment --- .../views/settings-view/providers/opencode-settings-tab.tsx | 1 + apps/ui/src/styles/global.css | 2 ++ 2 files changed, 3 insertions(+) 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 f56e9a64..4321b6d8 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 @@ -60,6 +60,7 @@ export function OpencodeSettingsTab() { hasApiKey: cliStatusData.auth.hasApiKey, hasEnvApiKey: cliStatusData.auth.hasEnvApiKey, hasOAuthToken: cliStatusData.auth.hasOAuthToken, + error: cliStatusData.auth.error, }; }, [cliStatusData]); diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 3e2ae46d..6e942b88 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -132,6 +132,7 @@ :root { /* Default to light mode */ --radius: 0.625rem; + --perf-contain-intrinsic-size: 500px; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); @@ -1124,4 +1125,5 @@ .perf-contain { contain: layout paint; content-visibility: auto; + contain-intrinsic-size: auto var(--perf-contain-intrinsic-size); } From a863dcc11de0194ca2edd8db0b405d7395776f51 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Tue, 20 Jan 2026 19:50:15 +0530 Subject: [PATCH 17/21] fix(ui): handle review feedback --- apps/ui/src/components/views/running-agents-view.tsx | 9 +++++++-- apps/ui/src/hooks/use-project-settings-loader.ts | 12 ++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx index faa23979..4265650b 100644 --- a/apps/ui/src/components/views/running-agents-view.tsx +++ b/apps/ui/src/components/views/running-agents-view.tsx @@ -44,8 +44,13 @@ export function RunningAgentsView() { const isBacklogPlan = agent.featureId.startsWith('backlog-plan:'); if (isBacklogPlan && api.backlogPlan) { logger.debug('Stopping backlog plan agent', { featureId: agent.featureId }); - await api.backlogPlan.stop(); - refetch(); + try { + await api.backlogPlan.stop(); + } catch (error) { + logger.error('Failed to stop backlog plan', { featureId: agent.featureId, error }); + } finally { + refetch(); + } return; } // Use mutation for regular features diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index de8d70f2..a4531d22 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -27,10 +27,10 @@ export function useProjectSettingsLoader() { ); const setCurrentProject = useAppStore((state) => state.setCurrentProject); - const appliedProjectRef = useRef(null); + const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null); // Fetch project settings with React Query - const { data: settings } = useProjectSettings(currentProject?.path); + const { data: settings, dataUpdatedAt } = useProjectSettings(currentProject?.path); // Apply settings when data changes useEffect(() => { @@ -39,11 +39,14 @@ export function useProjectSettingsLoader() { } // Prevent applying the same settings multiple times - if (appliedProjectRef.current === currentProject.path) { + if ( + appliedProjectRef.current?.path === currentProject.path && + appliedProjectRef.current?.dataUpdatedAt === dataUpdatedAt + ) { return; } - appliedProjectRef.current = currentProject.path; + appliedProjectRef.current = { path: currentProject.path, dataUpdatedAt }; const projectPath = currentProject.path; const bg = settings.boardBackground; @@ -109,6 +112,7 @@ export function useProjectSettingsLoader() { }, [ currentProject?.path, settings, + dataUpdatedAt, setBoardBackground, setCardOpacity, setColumnOpacity, From 8c356d7c36ace2e419cc5816bb706afd0c270228 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Tue, 20 Jan 2026 20:15:15 +0530 Subject: [PATCH 18/21] fix(ui): sync updated feature query --- .../views/board-view/hooks/use-board-persistence.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index 987d5541..4c809631 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -48,7 +48,17 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps feature: result.feature, }); if (result.success && result.feature) { - updateFeature(result.feature.id, result.feature); + const updatedFeature = result.feature; + updateFeature(updatedFeature.id, updatedFeature); + queryClient.setQueryData( + queryKeys.features.all(currentProject.path), + (features) => { + if (!features) return features; + return features.map((feature) => + feature.id === updatedFeature.id ? updatedFeature : feature + ); + } + ); // Invalidate React Query cache to sync UI queryClient.invalidateQueries({ queryKey: queryKeys.features.all(currentProject.path), From 76eb3a2ac25cba0604022a28340625d010cfe984 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Tue, 20 Jan 2026 10:24:38 -0500 Subject: [PATCH 19/21] apply the patches --- apps/server/src/index.ts | 2 +- .../routes/auto-mode/routes/run-feature.ts | 18 + .../src/routes/backlog-plan/routes/apply.ts | 3 +- apps/server/src/routes/worktree/index.ts | 9 + .../routes/worktree/routes/list-branches.ts | 18 +- .../routes/worktree/routes/list-remotes.ts | 127 ++++ .../src/routes/worktree/routes/merge.ts | 103 ++- .../server/src/routes/worktree/routes/push.ts | 12 +- apps/server/src/services/auto-mode-service.ts | 183 ++++- .../server/src/services/event-hook-service.ts | 23 +- apps/server/src/services/settings-service.ts | 29 +- apps/ui/src/components/views/board-view.tsx | 433 +++++++----- .../components/kanban-card/kanban-card.tsx | 37 +- .../components/list-view/list-header.tsx | 11 +- .../components/list-view/list-row.tsx | 38 +- .../dialogs/dependency-link-dialog.tsx | 135 ++++ .../views/board-view/dialogs/index.ts | 4 + .../dialogs/merge-worktree-dialog.tsx | 326 ++++++--- .../dialogs/pull-resolve-conflicts-dialog.tsx | 303 ++++++++ .../dialogs/push-to-remote-dialog.tsx | 242 +++++++ .../board-view/hooks/use-board-actions.ts | 21 +- .../board-view/hooks/use-board-drag-drop.ts | 105 ++- .../board-view/hooks/use-board-effects.ts | 34 +- .../board-view/hooks/use-board-features.ts | 4 +- .../views/board-view/kanban-board.tsx | 648 +++++++++--------- .../components/worktree-actions-dropdown.tsx | 71 +- .../components/worktree-tab.tsx | 25 +- .../worktree-panel/hooks/use-branches.ts | 2 + .../hooks/use-running-features.ts | 5 + .../views/board-view/worktree-panel/types.ts | 10 +- .../worktree-panel/worktree-panel.tsx | 111 ++- apps/ui/src/hooks/queries/use-worktrees.ts | 4 + apps/ui/src/hooks/use-auto-mode.ts | 47 +- apps/ui/src/hooks/use-settings-migration.ts | 2 + apps/ui/src/lib/electron.ts | 33 +- apps/ui/src/lib/http-api-client.ts | 16 +- apps/ui/src/store/app-store.ts | 17 +- apps/ui/src/types/electron.d.ts | 40 +- .../tests/features/list-view-priority.spec.ts | 162 +++++ libs/prompts/src/defaults.ts | 5 +- libs/types/src/settings.ts | 13 + start-automaker.sh | 5 +- 42 files changed, 2679 insertions(+), 757 deletions(-) create mode 100644 apps/server/src/routes/worktree/routes/list-remotes.ts create mode 100644 apps/ui/src/components/views/board-view/dialogs/dependency-link-dialog.tsx create mode 100644 apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx create mode 100644 apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx create mode 100644 apps/ui/tests/features/list-view-priority.spec.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 43c65992..3c90fd38 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -249,7 +249,7 @@ notificationService.setEventEmitter(events); const eventHistoryService = getEventHistoryService(); // Initialize Event Hook Service for custom event triggers (with history storage) -eventHookService.initialize(events, settingsService, eventHistoryService); +eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader); // Initialize services (async () => { diff --git a/apps/server/src/routes/auto-mode/routes/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts index 1bec9368..2d53c8e5 100644 --- a/apps/server/src/routes/auto-mode/routes/run-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -26,6 +26,24 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) { return; } + // Check per-worktree capacity before starting + const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId); + if (!capacity.hasCapacity) { + const worktreeDesc = capacity.branchName + ? `worktree "${capacity.branchName}"` + : 'main worktree'; + res.status(429).json({ + success: false, + error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`, + details: { + currentAgents: capacity.currentAgents, + maxAgents: capacity.maxAgents, + branchName: capacity.branchName, + }, + }); + return; + } + // Start execution in background // executeFeature derives workDir from feature.branchName autoModeService diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index 9e0ae999..1a238d17 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -85,8 +85,9 @@ export function createApplyHandler() { if (!change.feature) continue; try { - // Create the new feature + // Create the new feature - use the AI-generated ID if provided const newFeature = await featureLoader.create(projectPath, { + id: change.feature.id, // Use descriptive ID from AI if provided title: change.feature.title, description: change.feature.description || '', category: change.feature.category || 'Uncategorized', diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index d4358b65..7459ca57 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -49,6 +49,7 @@ import { createRunInitScriptHandler, } from './routes/init-script.js'; import { createDiscardChangesHandler } from './routes/discard-changes.js'; +import { createListRemotesHandler } from './routes/list-remotes.js'; import type { SettingsService } from '../../services/settings-service.js'; export function createWorktreeRoutes( @@ -157,5 +158,13 @@ export function createWorktreeRoutes( createDiscardChangesHandler() ); + // List remotes route + router.post( + '/list-remotes', + validatePathParams('worktreePath'), + requireValidWorktree, + createListRemotesHandler() + ); + return router; } diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index c6db10fc..6c999552 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -110,9 +110,10 @@ export function createListBranchesHandler() { } } - // Get ahead/behind count for current branch + // Get ahead/behind count for current branch and check if remote branch exists let aheadCount = 0; let behindCount = 0; + let hasRemoteBranch = false; try { // First check if there's a remote tracking branch const { stdout: upstreamOutput } = await execAsync( @@ -121,6 +122,7 @@ export function createListBranchesHandler() { ); if (upstreamOutput.trim()) { + hasRemoteBranch = true; const { stdout: aheadBehindOutput } = await execAsync( `git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`, { cwd: worktreePath } @@ -130,7 +132,18 @@ export function createListBranchesHandler() { behindCount = behind || 0; } } catch { - // No upstream branch set, that's okay + // No upstream branch set - check if the branch exists on any remote + try { + // Check if there's a matching branch on origin (most common remote) + const { stdout: remoteBranchOutput } = await execAsync( + `git ls-remote --heads origin ${currentBranch}`, + { cwd: worktreePath, timeout: 5000 } + ); + hasRemoteBranch = remoteBranchOutput.trim().length > 0; + } catch { + // No remote branch found or origin doesn't exist + hasRemoteBranch = false; + } } res.json({ @@ -140,6 +153,7 @@ export function createListBranchesHandler() { branches, aheadCount, behindCount, + hasRemoteBranch, }, }); } catch (error) { diff --git a/apps/server/src/routes/worktree/routes/list-remotes.ts b/apps/server/src/routes/worktree/routes/list-remotes.ts new file mode 100644 index 00000000..1180afce --- /dev/null +++ b/apps/server/src/routes/worktree/routes/list-remotes.ts @@ -0,0 +1,127 @@ +/** + * POST /list-remotes endpoint - List all remotes and their branches + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logWorktreeError } from '../common.js'; + +const execAsync = promisify(exec); + +interface RemoteBranch { + name: string; + fullRef: string; +} + +interface RemoteInfo { + name: string; + url: string; + branches: RemoteBranch[]; +} + +export function createListRemotesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Get list of remotes + const { stdout: remotesOutput } = await execAsync('git remote -v', { + cwd: worktreePath, + }); + + // Parse remotes (each remote appears twice - once for fetch, once for push) + const remotesSet = new Map(); + remotesOutput + .trim() + .split('\n') + .filter((line) => line.trim()) + .forEach((line) => { + const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/); + if (match) { + remotesSet.set(match[1], match[2]); + } + }); + + // Fetch latest from all remotes (silently, don't fail if offline) + try { + await execAsync('git fetch --all --quiet', { + cwd: worktreePath, + timeout: 15000, // 15 second timeout + }); + } catch { + // Ignore fetch errors - we'll use cached remote refs + } + + // Get all remote branches + const { stdout: remoteBranchesOutput } = await execAsync( + 'git branch -r --format="%(refname:short)"', + { cwd: worktreePath } + ); + + // Group branches by remote + const remotesBranches = new Map(); + remotesSet.forEach((_, remoteName) => { + remotesBranches.set(remoteName, []); + }); + + remoteBranchesOutput + .trim() + .split('\n') + .filter((line) => line.trim()) + .forEach((line) => { + const cleanLine = line.trim().replace(/^['"]|['"]$/g, ''); + // Skip HEAD pointers like "origin/HEAD" + if (cleanLine.includes('/HEAD')) return; + + // Parse remote name from branch ref (e.g., "origin/main" -> "origin") + const slashIndex = cleanLine.indexOf('/'); + if (slashIndex === -1) return; + + const remoteName = cleanLine.substring(0, slashIndex); + const branchName = cleanLine.substring(slashIndex + 1); + + if (remotesBranches.has(remoteName)) { + remotesBranches.get(remoteName)!.push({ + name: branchName, + fullRef: cleanLine, + }); + } + }); + + // Build final result + const remotes: RemoteInfo[] = []; + remotesSet.forEach((url, name) => { + remotes.push({ + name, + url, + branches: remotesBranches.get(name) || [], + }); + }); + + res.json({ + success: true, + result: { + remotes, + }, + }); + } catch (error) { + const worktreePath = req.body?.worktreePath; + logWorktreeError(error, 'List remotes failed', worktreePath); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts index 69f120b8..48df7893 100644 --- a/apps/server/src/routes/worktree/routes/merge.ts +++ b/apps/server/src/routes/worktree/routes/merge.ts @@ -1,5 +1,7 @@ /** - * POST /merge endpoint - Merge feature (merge worktree branch into main) + * POST /merge endpoint - Merge feature (merge worktree branch into a target branch) + * + * Allows merging a worktree branch into any target branch (defaults to 'main'). * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidProject middleware in index.ts @@ -8,18 +10,21 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; -import { getErrorMessage, logError } from '../common.js'; +import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js'; +import { createLogger } from '@automaker/utils'; const execAsync = promisify(exec); +const logger = createLogger('Worktree'); export function createMergeHandler() { return async (req: Request, res: Response): Promise => { try { - const { projectPath, branchName, worktreePath, options } = req.body as { + const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as { projectPath: string; branchName: string; worktreePath: string; - options?: { squash?: boolean; message?: string }; + targetBranch?: string; // Branch to merge into (defaults to 'main') + options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean }; }; if (!projectPath || !branchName || !worktreePath) { @@ -30,7 +35,10 @@ export function createMergeHandler() { return; } - // Validate branch exists + // Determine the target branch (default to 'main') + const mergeTo = targetBranch || 'main'; + + // Validate source branch exists try { await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); } catch { @@ -41,12 +49,44 @@ export function createMergeHandler() { return; } - // Merge the feature branch + // Validate target branch exists + try { + await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath }); + } catch { + res.status(400).json({ + success: false, + error: `Target branch "${mergeTo}" does not exist`, + }); + return; + } + + // Merge the feature branch into the target branch const mergeCmd = options?.squash ? `git merge --squash ${branchName}` - : `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`; + : `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`; - await execAsync(mergeCmd, { cwd: projectPath }); + try { + await execAsync(mergeCmd, { cwd: projectPath }); + } catch (mergeError: unknown) { + // Check if this is a merge conflict + const err = mergeError as { stdout?: string; stderr?: string; message?: string }; + const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; + const hasConflicts = + output.includes('CONFLICT') || output.includes('Automatic merge failed'); + + if (hasConflicts) { + // Return conflict-specific error message that frontend can detect + res.status(409).json({ + success: false, + error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`, + hasConflicts: true, + }); + return; + } + + // Re-throw non-conflict errors to be handled by outer catch + throw mergeError; + } // If squash merge, need to commit if (options?.squash) { @@ -55,17 +95,46 @@ export function createMergeHandler() { }); } - // Clean up worktree and branch - try { - await execAsync(`git worktree remove "${worktreePath}" --force`, { - cwd: projectPath, - }); - await execAsync(`git branch -D ${branchName}`, { cwd: projectPath }); - } catch { - // Cleanup errors are non-fatal + // Optionally delete the worktree and branch after merging + let worktreeDeleted = false; + let branchDeleted = false; + + if (options?.deleteWorktreeAndBranch) { + // Remove the worktree + try { + await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); + worktreeDeleted = true; + } catch { + // Try with prune if remove fails + try { + await execGitCommand(['worktree', 'prune'], projectPath); + worktreeDeleted = true; + } catch { + logger.warn(`Failed to remove worktree: ${worktreePath}`); + } + } + + // Delete the branch (but not main/master) + if (branchName !== 'main' && branchName !== 'master') { + if (!isValidBranchName(branchName)) { + logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); + } else { + try { + await execGitCommand(['branch', '-D', branchName], projectPath); + branchDeleted = true; + } catch { + logger.warn(`Failed to delete branch: ${branchName}`); + } + } + } } - res.json({ success: true, mergedBranch: branchName }); + res.json({ + success: true, + mergedBranch: branchName, + targetBranch: mergeTo, + deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined, + }); } catch (error) { logError(error, 'Merge worktree failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/worktree/routes/push.ts b/apps/server/src/routes/worktree/routes/push.ts index b044ba00..0e082b3f 100644 --- a/apps/server/src/routes/worktree/routes/push.ts +++ b/apps/server/src/routes/worktree/routes/push.ts @@ -15,9 +15,10 @@ const execAsync = promisify(exec); export function createPushHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, force } = req.body as { + const { worktreePath, force, remote } = req.body as { worktreePath: string; force?: boolean; + remote?: string; }; if (!worktreePath) { @@ -34,15 +35,18 @@ export function createPushHandler() { }); const branchName = branchOutput.trim(); + // Use specified remote or default to 'origin' + const targetRemote = remote || 'origin'; + // Push the branch const forceFlag = force ? '--force' : ''; try { - await execAsync(`git push -u origin ${branchName} ${forceFlag}`, { + await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, { cwd: worktreePath, }); } catch { // Try setting upstream - await execAsync(`git push --set-upstream origin ${branchName} ${forceFlag}`, { + await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, { cwd: worktreePath, }); } @@ -52,7 +56,7 @@ export function createPushHandler() { result: { branch: branchName, pushed: true, - message: `Successfully pushed ${branchName} to origin`, + message: `Successfully pushed ${branchName} to ${targetRemote}`, }, }); } catch (error) { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 28498829..9eeefc14 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -248,7 +248,8 @@ interface AutoModeConfig { * @param branchName - The branch name, or null for main worktree */ function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { - return `${projectPath}::${branchName ?? '__main__'}`; + const normalizedBranch = branchName === 'main' ? null : branchName; + return `${projectPath}::${normalizedBranch ?? '__main__'}`; } /** @@ -514,14 +515,11 @@ export class AutoModeService { ? settings.maxConcurrency : DEFAULT_MAX_CONCURRENCY; const projectId = settings.projects?.find((project) => project.path === projectPath)?.id; - const autoModeByWorktree = (settings as unknown as Record) - .autoModeByWorktree; + const autoModeByWorktree = settings.autoModeByWorktree; if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { const key = `${projectId}::${branchName ?? '__main__'}`; - const entry = (autoModeByWorktree as Record)[key] as - | { maxConcurrency?: number } - | undefined; + const entry = autoModeByWorktree[key]; if (entry && typeof entry.maxConcurrency === 'number') { return entry.maxConcurrency; } @@ -592,6 +590,7 @@ export class AutoModeService { message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, projectPath, branchName, + maxConcurrency: resolvedMaxConcurrency, }); // Save execution state for recovery after restart @@ -677,8 +676,10 @@ export class AutoModeService { continue; } - // Find a feature not currently running - const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); + // Find a feature not currently running and not yet finished + const nextFeature = pendingFeatures.find( + (f) => !this.runningFeatures.has(f.id) && !this.isFeatureFinished(f) + ); if (nextFeature) { logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`); @@ -730,11 +731,12 @@ export class AutoModeService { * @param branchName - The branch name, or null for main worktree (features without branchName or with "main") */ private getRunningCountForWorktree(projectPath: string, branchName: string | null): number { + const normalizedBranch = branchName === 'main' ? null : branchName; let count = 0; for (const [, feature] of this.runningFeatures) { // Filter by project path AND branchName to get accurate worktree-specific count const featureBranch = feature.branchName ?? null; - if (branchName === null) { + if (normalizedBranch === null) { // Main worktree: match features with branchName === null OR branchName === "main" if ( feature.projectPath === projectPath && @@ -998,6 +1000,41 @@ export class AutoModeService { return this.runningFeatures.size; } + /** + * Check if there's capacity to start a feature on a worktree. + * This respects per-worktree agent limits from autoModeByWorktree settings. + * + * @param projectPath - The main project path + * @param featureId - The feature ID to check capacity for + * @returns Object with hasCapacity boolean and details about current/max agents + */ + async checkWorktreeCapacity( + projectPath: string, + featureId: string + ): Promise<{ + hasCapacity: boolean; + currentAgents: number; + maxAgents: number; + branchName: string | null; + }> { + // Load feature to get branchName + const feature = await this.loadFeature(projectPath, featureId); + const branchName = feature?.branchName ?? null; + + // Get per-worktree limit + const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName); + + // Get current running count for this worktree + const currentAgents = this.getRunningCountForWorktree(projectPath, branchName); + + return { + hasCapacity: currentAgents < maxAgents, + currentAgents, + maxAgents, + branchName, + }; + } + /** * Execute a single feature * @param projectPath - The main project path @@ -1036,7 +1073,6 @@ export class AutoModeService { if (isAutoMode) { await this.saveExecutionState(projectPath); } - // Declare feature outside try block so it's available in catch for error reporting let feature: Awaited> | null = null; @@ -1044,9 +1080,44 @@ export class AutoModeService { // Validate that project path is allowed using centralized validation validateWorkingDirectory(projectPath); + // Load feature details FIRST to get status and plan info + feature = await this.loadFeature(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + // Check if feature has existing context - if so, resume instead of starting fresh // Skip this check if we're already being called with a continuation prompt (from resumeFeature) if (!options?.continuationPrompt) { + // If feature has an approved plan but we don't have a continuation prompt yet, + // we should build one to ensure it proceeds with multi-agent execution + if (feature.planSpec?.status === 'approved') { + logger.info(`Feature ${featureId} has approved plan, building continuation prompt`); + + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + const planContent = feature.planSpec.content || ''; + + // Build continuation prompt using centralized template + let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; + continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, ''); + continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); + + // Recursively call executeFeature with the continuation prompt + // Remove from running features temporarily, it will be added back + this.runningFeatures.delete(featureId); + return this.executeFeature( + projectPath, + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + { + continuationPrompt, + } + ); + } + const hasExistingContext = await this.contextExists(projectPath, featureId); if (hasExistingContext) { logger.info( @@ -1058,12 +1129,6 @@ export class AutoModeService { } } - // Load feature details FIRST to get branchName - feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); - } - // Derive workDir from feature.branchName // Worktrees should already be created when the feature is added/edited let worktreePath: string | null = null; @@ -1190,6 +1255,7 @@ export class AutoModeService { systemPrompt: combinedSystemPrompt || undefined, autoLoadClaudeMd, thinkingLevel: feature.thinkingLevel, + branchName: feature.branchName ?? null, } ); @@ -1361,6 +1427,7 @@ export class AutoModeService { this.emitAutoModeEvent('auto_mode_progress', { featureId, + branchName: feature.branchName ?? null, content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`, projectPath, }); @@ -2805,6 +2872,21 @@ Format your response as a structured markdown document.`; } } + private isFeatureFinished(feature: Feature): boolean { + const isCompleted = feature.status === 'completed' || feature.status === 'verified'; + + // Even if marked as completed, if it has an approved plan with pending tasks, it's not finished + if (feature.planSpec?.status === 'approved') { + const tasksCompleted = feature.planSpec.tasksCompleted ?? 0; + const tasksTotal = feature.planSpec.tasksTotal ?? 0; + if (tasksCompleted < tasksTotal) { + return false; + } + } + + return isCompleted; + } + /** * Update the planSpec of a feature */ @@ -2899,10 +2981,14 @@ Format your response as a structured markdown document.`; allFeatures.push(feature); // Track pending features separately, filtered by worktree/branch + // Note: waiting_approval is NOT included - those features have completed execution + // and are waiting for user review, they should not be picked up again if ( feature.status === 'pending' || feature.status === 'ready' || - feature.status === 'backlog' + feature.status === 'backlog' || + (feature.planSpec?.status === 'approved' && + (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) ) { // Filter by branchName: // - If branchName is null (main worktree), include features with branchName === null OR branchName === "main" @@ -2934,7 +3020,7 @@ Format your response as a structured markdown document.`; const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status for ${worktreeDesc}` + `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/approved_with_pending_tasks) for ${worktreeDesc}` ); if (pendingFeatures.length === 0) { @@ -2943,7 +3029,12 @@ Format your response as a structured markdown document.`; ); // Log all backlog features to help debug branchName matching const allBacklogFeatures = allFeatures.filter( - (f) => f.status === 'backlog' || f.status === 'pending' || f.status === 'ready' + (f) => + f.status === 'backlog' || + f.status === 'pending' || + f.status === 'ready' || + (f.planSpec?.status === 'approved' && + (f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0)) ); if (allBacklogFeatures.length > 0) { logger.info( @@ -2953,7 +3044,43 @@ Format your response as a structured markdown document.`; } // Apply dependency-aware ordering - const { orderedFeatures } = resolveDependencies(pendingFeatures); + const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures); + + // Remove missing dependencies from features and save them + // This allows features to proceed when their dependencies have been deleted or don't exist + if (missingDependencies.size > 0) { + for (const [featureId, missingDepIds] of missingDependencies) { + const feature = pendingFeatures.find((f) => f.id === featureId); + if (feature && feature.dependencies) { + // Filter out the missing dependency IDs + const validDependencies = feature.dependencies.filter( + (depId) => !missingDepIds.includes(depId) + ); + + logger.warn( + `[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.` + ); + + // Update the feature in memory + feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined; + + // Save the updated feature to disk + try { + await this.featureLoader.update(projectPath, featureId, { + dependencies: feature.dependencies, + }); + logger.info( + `[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies` + ); + } catch (error) { + logger.error( + `[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`, + error + ); + } + } + } + } // Get skipVerificationInAutoMode setting const settings = await this.settingsService?.getGlobalSettings(); @@ -3129,9 +3256,11 @@ You can use the Read tool to view these images at any time during implementation systemPrompt?: string; autoLoadClaudeMd?: boolean; thinkingLevel?: ThinkingLevel; + branchName?: string | null; } ): Promise { const finalProjectPath = options?.projectPath || projectPath; + const branchName = options?.branchName ?? null; const planningMode = options?.planningMode || 'skip'; const previousContent = options?.previousContent; @@ -3496,6 +3625,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. this.emitAutoModeEvent('plan_approval_required', { featureId, projectPath, + branchName, planContent: currentPlanContent, planningMode, planVersion, @@ -3527,6 +3657,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. this.emitAutoModeEvent('plan_approved', { featureId, projectPath, + branchName, hasEdits: !!approvalResult.editedPlan, planVersion, }); @@ -3555,6 +3686,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. this.emitAutoModeEvent('plan_revision_requested', { featureId, projectPath, + branchName, feedback: approvalResult.feedback, hasEdits: !!hasEdits, planVersion, @@ -3658,6 +3790,7 @@ After generating the revised spec, output: this.emitAutoModeEvent('plan_auto_approved', { featureId, projectPath, + branchName, planContent, planningMode, }); @@ -3708,6 +3841,7 @@ After generating the revised spec, output: this.emitAutoModeEvent('auto_mode_task_started', { featureId, projectPath, + branchName, taskId: task.id, taskDescription: task.description, taskIndex, @@ -3753,11 +3887,13 @@ After generating the revised spec, output: responseText += block.text || ''; this.emitAutoModeEvent('auto_mode_progress', { featureId, + branchName, content: block.text, }); } else if (block.type === 'tool_use') { this.emitAutoModeEvent('auto_mode_tool', { featureId, + branchName, tool: block.name, input: block.input, }); @@ -3776,6 +3912,7 @@ After generating the revised spec, output: this.emitAutoModeEvent('auto_mode_task_complete', { featureId, projectPath, + branchName, taskId: task.id, tasksCompleted: taskIndex + 1, tasksTotal: parsedTasks.length, @@ -3796,6 +3933,7 @@ After generating the revised spec, output: this.emitAutoModeEvent('auto_mode_phase_complete', { featureId, projectPath, + branchName, phaseNumber: parseInt(phaseMatch[1], 10), }); } @@ -3845,11 +3983,13 @@ After generating the revised spec, output: responseText += block.text || ''; this.emitAutoModeEvent('auto_mode_progress', { featureId, + branchName, content: block.text, }); } else if (block.type === 'tool_use') { this.emitAutoModeEvent('auto_mode_tool', { featureId, + branchName, tool: block.name, input: block.input, }); @@ -3875,6 +4015,7 @@ After generating the revised spec, output: ); this.emitAutoModeEvent('auto_mode_progress', { featureId, + branchName, content: block.text, }); } @@ -3882,6 +4023,7 @@ After generating the revised spec, output: // Emit event for real-time UI this.emitAutoModeEvent('auto_mode_tool', { featureId, + branchName, tool: block.name, input: block.input, }); @@ -4287,6 +4429,7 @@ After generating the revised spec, output: id: f.id, title: f.title, status: f.status, + branchName: f.branchName ?? null, })), }); diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index 74070b78..9f73155f 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -21,6 +21,7 @@ import { createLogger } from '@automaker/utils'; import type { EventEmitter } from '../lib/events.js'; import type { SettingsService } from './settings-service.js'; import type { EventHistoryService } from './event-history-service.js'; +import type { FeatureLoader } from './feature-loader.js'; import type { EventHook, EventHookTrigger, @@ -84,19 +85,22 @@ export class EventHookService { private emitter: EventEmitter | null = null; private settingsService: SettingsService | null = null; private eventHistoryService: EventHistoryService | null = null; + private featureLoader: FeatureLoader | null = null; private unsubscribe: (() => void) | null = null; /** - * Initialize the service with event emitter, settings service, and event history service + * Initialize the service with event emitter, settings service, event history service, and feature loader */ initialize( emitter: EventEmitter, settingsService: SettingsService, - eventHistoryService?: EventHistoryService + eventHistoryService?: EventHistoryService, + featureLoader?: FeatureLoader ): void { this.emitter = emitter; this.settingsService = settingsService; this.eventHistoryService = eventHistoryService || null; + this.featureLoader = featureLoader || null; // Subscribe to events this.unsubscribe = emitter.subscribe((type, payload) => { @@ -121,6 +125,7 @@ export class EventHookService { this.emitter = null; this.settingsService = null; this.eventHistoryService = null; + this.featureLoader = null; } /** @@ -150,6 +155,19 @@ export class EventHookService { if (!trigger) return; + // Load feature name if we have featureId but no featureName + let featureName: string | undefined = undefined; + if (payload.featureId && payload.projectPath && this.featureLoader) { + try { + const feature = await this.featureLoader.get(payload.projectPath, payload.featureId); + if (feature?.title) { + featureName = feature.title; + } + } catch (error) { + logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error); + } + } + // Build context for variable substitution const context: HookContext = { featureId: payload.featureId, @@ -315,6 +333,7 @@ export class EventHookService { eventType: context.eventType, timestamp: context.timestamp, featureId: context.featureId, + featureName: context.featureName, projectPath: context.projectPath, projectName: context.projectName, error: context.error, diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 18eafcc3..2cfb78c4 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -415,16 +415,25 @@ export class SettingsService { ignoreEmptyArrayOverwrite('claudeApiProfiles'); // Empty object overwrite guard - if ( - sanitizedUpdates.lastSelectedSessionByProject && - typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' && - !Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) && - Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 && - current.lastSelectedSessionByProject && - Object.keys(current.lastSelectedSessionByProject).length > 0 - ) { - delete sanitizedUpdates.lastSelectedSessionByProject; - } + const ignoreEmptyObjectOverwrite = (key: K): void => { + const nextVal = sanitizedUpdates[key] as unknown; + const curVal = current[key] as unknown; + if ( + nextVal && + typeof nextVal === 'object' && + !Array.isArray(nextVal) && + Object.keys(nextVal).length === 0 && + curVal && + typeof curVal === 'object' && + !Array.isArray(curVal) && + Object.keys(curVal).length > 0 + ) { + delete sanitizedUpdates[key]; + } + }; + + ignoreEmptyObjectOverwrite('lastSelectedSessionByProject'); + ignoreEmptyObjectOverwrite('autoModeByWorktree'); // If a request attempted to wipe projects, also ignore theme changes in that same request. if (attemptedProjectWipe) { diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2e2222ba..7b55cb60 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { + DndContext, PointerSensor, useSensor, useSensors, @@ -49,19 +50,21 @@ import { CompletedFeaturesModal, ArchiveAllVerifiedDialog, DeleteCompletedFeatureDialog, + DependencyLinkDialog, EditFeatureDialog, FollowUpDialog, PlanApprovalDialog, + PullResolveConflictsDialog, } from './board-view/dialogs'; +import type { DependencyLinkType } from './board-view/dialogs'; import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog'; import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog'; import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog'; import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog'; import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog'; import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog'; -import { MergeWorktreeDialog } from './board-view/dialogs/merge-worktree-dialog'; import { WorktreePanel } from './board-view/worktree-panel'; -import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types'; +import type { PRInfo, WorktreeInfo, MergeConflictInfo } from './board-view/worktree-panel/types'; import { COLUMNS, getColumnsWithPipeline } from './board-view/constants'; import { useBoardFeatures, @@ -182,7 +185,7 @@ export function BoardView() { const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false); const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false); - const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false); + const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false); const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{ path: string; branch: string; @@ -359,10 +362,22 @@ export function BoardView() { fetchBranches(); }, [currentProject, worktreeRefreshKey]); - // Custom collision detection that prioritizes columns over cards + // Custom collision detection that prioritizes specific drop targets (cards, worktrees) over columns const collisionDetectionStrategy = useCallback((args: any) => { - // First, check if pointer is within a column const pointerCollisions = pointerWithin(args); + + // Priority 1: Specific drop targets (cards for dependency links, worktrees) + // These need to be detected even if they are inside a column + const specificTargetCollisions = pointerCollisions.filter((collision: any) => { + const id = String(collision.id); + return id.startsWith('card-drop-') || id.startsWith('worktree-drop-'); + }); + + if (specificTargetCollisions.length > 0) { + return specificTargetCollisions; + } + + // Priority 2: Columns const columnCollisions = pointerCollisions.filter((collision: any) => COLUMNS.some((col) => col.id === collision.id) ); @@ -372,7 +387,7 @@ export function BoardView() { return columnCollisions; } - // Otherwise, use rectangle intersection for cards + // Priority 3: Fallback to rectangle intersection return rectIntersection(args); }, []); @@ -830,10 +845,15 @@ export function BoardView() { [handleAddFeature, handleStartImplementation, defaultSkipTests] ); - // Handler for resolving conflicts - creates a feature to pull from the remote branch and resolve conflicts - const handleResolveConflicts = useCallback( - async (worktree: WorktreeInfo) => { - const remoteBranch = `origin/${worktree.branch}`; + // Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature + const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => { + setSelectedWorktreeForAction(worktree); + setShowPullResolveConflictsDialog(true); + }, []); + + // Handler called when user confirms the pull & resolve conflicts dialog + const handleConfirmResolveConflicts = useCallback( + async (worktree: WorktreeInfo, remoteBranch: string) => { const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`; // Create the feature @@ -873,6 +893,48 @@ export function BoardView() { [handleAddFeature, handleStartImplementation, defaultSkipTests] ); + // Handler called when merge fails due to conflicts and user wants to create a feature to resolve them + const handleCreateMergeConflictResolutionFeature = useCallback( + async (conflictInfo: MergeConflictInfo) => { + const description = `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.`; + + // Create the feature + const featureData = { + title: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`, + category: 'Maintenance', + description, + images: [], + imagePaths: [], + skipTests: defaultSkipTests, + model: 'opus' as const, + thinkingLevel: 'none' as const, + branchName: conflictInfo.targetBranch, + workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved + priority: 1, // High priority for conflict resolution + planningMode: 'skip' as const, + requirePlanApproval: false, + }; + + // Capture existing feature IDs before adding + const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); + await handleAddFeature(featureData); + + // Find the newly created feature by looking for an ID that wasn't in the original set + const latestFeatures = useAppStore.getState().features; + const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); + + if (newFeature) { + await handleStartImplementation(newFeature); + } else { + logger.error('Could not find newly created feature to start it automatically.'); + toast.error('Failed to auto-start feature', { + description: 'The feature was created but could not be started automatically.', + }); + } + }, + [handleAddFeature, handleStartImplementation, defaultSkipTests] + ); + // Handler for "Make" button - creates a feature and immediately starts it const handleAddAndStartFeature = useCallback( async (featureData: Parameters[0]) => { @@ -967,7 +1029,13 @@ export function BoardView() { }); // Use drag and drop hook - const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({ + const { + activeFeature, + handleDragStart, + handleDragEnd, + pendingDependencyLink, + clearPendingDependencyLink, + } = useBoardDragDrop({ features: hookFeatures, currentProject, runningAutoTasks, @@ -975,6 +1043,50 @@ export function BoardView() { handleStartImplementation, }); + // Handle dependency link creation + const handleCreateDependencyLink = useCallback( + async (linkType: DependencyLinkType) => { + if (!pendingDependencyLink || !currentProject) return; + + const { draggedFeature, targetFeature } = pendingDependencyLink; + + if (linkType === 'parent') { + // Dragged feature depends on target (target is parent) + // Add targetFeature.id to draggedFeature.dependencies + const currentDeps = draggedFeature.dependencies || []; + if (!currentDeps.includes(targetFeature.id)) { + const newDeps = [...currentDeps, targetFeature.id]; + updateFeature(draggedFeature.id, { dependencies: newDeps }); + await persistFeatureUpdate(draggedFeature.id, { dependencies: newDeps }); + toast.success('Dependency link created', { + description: `"${draggedFeature.description.slice(0, 30)}..." now depends on "${targetFeature.description.slice(0, 30)}..."`, + }); + } + } else { + // Target feature depends on dragged (dragged is parent) + // Add draggedFeature.id to targetFeature.dependencies + const currentDeps = targetFeature.dependencies || []; + if (!currentDeps.includes(draggedFeature.id)) { + const newDeps = [...currentDeps, draggedFeature.id]; + updateFeature(targetFeature.id, { dependencies: newDeps }); + await persistFeatureUpdate(targetFeature.id, { dependencies: newDeps }); + toast.success('Dependency link created', { + description: `"${targetFeature.description.slice(0, 30)}..." now depends on "${draggedFeature.description.slice(0, 30)}..."`, + }); + } + } + + clearPendingDependencyLink(); + }, + [ + pendingDependencyLink, + currentProject, + updateFeature, + persistFeatureUpdate, + clearPendingDependencyLink, + ] + ); + // Use column features hook const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({ features: hookFeatures, @@ -1205,133 +1317,148 @@ export function BoardView() { onViewModeChange={setViewMode} /> - {/* Worktree Panel - conditionally rendered based on visibility setting */} - {(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( - setShowCreateWorktreeDialog(true)} - onDeleteWorktree={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowDeleteWorktreeDialog(true); - }} - onCommit={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowCommitWorktreeDialog(true); - }} - onCreatePR={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowCreatePRDialog(true); - }} - onCreateBranch={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowCreateBranchDialog(true); - }} - onAddressPRComments={handleAddressPRComments} - onResolveConflicts={handleResolveConflicts} - onMerge={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowMergeWorktreeDialog(true); - }} - onRemovedWorktrees={handleRemovedWorktrees} - runningFeatureIds={runningAutoTasks} - branchCardCounts={branchCardCounts} - features={hookFeatures.map((f) => ({ - id: f.id, - branchName: f.branchName, - }))} - /> - )} - - {/* Main Content Area */} -
- {/* View Content - Kanban Board or List View */} - {isListView ? ( - setEditingFeature(feature), - onDelete: (featureId) => handleDeleteFeature(featureId), - onViewOutput: handleViewOutput, - onVerify: handleVerifyFeature, - onResume: handleResumeFeature, - onForceStop: handleForceStopFeature, - onManualVerify: handleManualVerify, - onFollowUp: handleOpenFollowUp, - onImplement: handleStartImplementation, - onComplete: handleCompleteFeature, - onViewPlan: (feature) => setViewPlanFeature(feature), - onApprovePlan: handleOpenApprovalDialog, - onSpawnTask: (feature) => { - setSpawnParentFeature(feature); - setShowAddDialog(true); - }, + {/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */} + + {/* Worktree Panel - conditionally rendered based on visibility setting */} + {(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( + setShowCreateWorktreeDialog(true)} + onDeleteWorktree={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowDeleteWorktreeDialog(true); }} - runningAutoTasks={runningAutoTasks} - pipelineConfig={pipelineConfig} - onAddFeature={() => setShowAddDialog(true)} - isSelectionMode={isSelectionMode} - selectedFeatureIds={selectedFeatureIds} - onToggleFeatureSelection={toggleFeatureSelection} - onRowClick={(feature) => { - if (feature.status === 'backlog') { - setEditingFeature(feature); - } else { - handleViewOutput(feature); - } + onCommit={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCommitWorktreeDialog(true); }} - className="transition-opacity duration-200" - /> - ) : ( - setEditingFeature(feature)} - onDelete={(featureId) => handleDeleteFeature(featureId)} - onViewOutput={handleViewOutput} - onVerify={handleVerifyFeature} - onResume={handleResumeFeature} - onForceStop={handleForceStopFeature} - onManualVerify={handleManualVerify} - onMoveBackToInProgress={handleMoveBackToInProgress} - onFollowUp={handleOpenFollowUp} - onComplete={handleCompleteFeature} - onImplement={handleStartImplementation} - onViewPlan={(feature) => setViewPlanFeature(feature)} - onApprovePlan={handleOpenApprovalDialog} - onSpawnTask={(feature) => { - setSpawnParentFeature(feature); - setShowAddDialog(true); + onCreatePR={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreatePRDialog(true); }} - featuresWithContext={featuresWithContext} - runningAutoTasks={runningAutoTasks} - onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} - onAddFeature={() => setShowAddDialog(true)} - onShowCompletedModal={() => setShowCompletedModal(true)} - completedCount={completedFeatures.length} - pipelineConfig={pipelineConfig} - onOpenPipelineSettings={() => setShowPipelineSettings(true)} - isSelectionMode={isSelectionMode} - selectionTarget={selectionTarget} - selectedFeatureIds={selectedFeatureIds} - onToggleFeatureSelection={toggleFeatureSelection} - onToggleSelectionMode={toggleSelectionMode} - viewMode={viewMode} - isDragging={activeFeature !== null} - onAiSuggest={() => setShowPlanDialog(true)} - className="transition-opacity duration-200" + onCreateBranch={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreateBranchDialog(true); + }} + onAddressPRComments={handleAddressPRComments} + onResolveConflicts={handleResolveConflicts} + onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature} + onBranchDeletedDuringMerge={(branchName) => { + // Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog) + hookFeatures.forEach((feature) => { + if (feature.branchName === branchName) { + // Reset the feature's branch assignment - update both local state and persist + const updates = { + branchName: null as unknown as string | undefined, + }; + updateFeature(feature.id, updates); + persistFeatureUpdate(feature.id, updates); + } + }); + setWorktreeRefreshKey((k) => k + 1); + }} + onRemovedWorktrees={handleRemovedWorktrees} + runningFeatureIds={runningAutoTasks} + branchCardCounts={branchCardCounts} + features={hookFeatures.map((f) => ({ + id: f.id, + branchName: f.branchName, + }))} /> )} -
+ + {/* Main Content Area */} +
+ {/* View Content - Kanban Board or List View */} + {isListView ? ( + setEditingFeature(feature), + onDelete: (featureId) => handleDeleteFeature(featureId), + onViewOutput: handleViewOutput, + onVerify: handleVerifyFeature, + onResume: handleResumeFeature, + onForceStop: handleForceStopFeature, + onManualVerify: handleManualVerify, + onFollowUp: handleOpenFollowUp, + onImplement: handleStartImplementation, + onComplete: handleCompleteFeature, + onViewPlan: (feature) => setViewPlanFeature(feature), + onApprovePlan: handleOpenApprovalDialog, + onSpawnTask: (feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }, + }} + runningAutoTasks={runningAutoTasks} + pipelineConfig={pipelineConfig} + onAddFeature={() => setShowAddDialog(true)} + isSelectionMode={isSelectionMode} + selectedFeatureIds={selectedFeatureIds} + onToggleFeatureSelection={toggleFeatureSelection} + onRowClick={(feature) => { + if (feature.status === 'backlog') { + setEditingFeature(feature); + } else { + handleViewOutput(feature); + } + }} + className="transition-opacity duration-200" + /> + ) : ( + setEditingFeature(feature)} + onDelete={(featureId) => handleDeleteFeature(featureId)} + onViewOutput={handleViewOutput} + onVerify={handleVerifyFeature} + onResume={handleResumeFeature} + onForceStop={handleForceStopFeature} + onManualVerify={handleManualVerify} + onMoveBackToInProgress={handleMoveBackToInProgress} + onFollowUp={handleOpenFollowUp} + onComplete={handleCompleteFeature} + onImplement={handleStartImplementation} + onViewPlan={(feature) => setViewPlanFeature(feature)} + onApprovePlan={handleOpenApprovalDialog} + onSpawnTask={(feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }} + featuresWithContext={featuresWithContext} + runningAutoTasks={runningAutoTasks} + onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} + onAddFeature={() => setShowAddDialog(true)} + onShowCompletedModal={() => setShowCompletedModal(true)} + completedCount={completedFeatures.length} + pipelineConfig={pipelineConfig} + onOpenPipelineSettings={() => setShowPipelineSettings(true)} + isSelectionMode={isSelectionMode} + selectionTarget={selectionTarget} + selectedFeatureIds={selectedFeatureIds} + onToggleFeatureSelection={toggleFeatureSelection} + onToggleSelectionMode={toggleSelectionMode} + viewMode={viewMode} + isDragging={activeFeature !== null} + onAiSuggest={() => setShowPlanDialog(true)} + className="transition-opacity duration-200" + /> + )} +
+ {/* Selection Action Bar */} {isSelectionMode && ( @@ -1425,6 +1552,15 @@ export function BoardView() { forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch} /> + {/* Dependency Link Dialog */} + !open && clearPendingDependencyLink()} + draggedFeature={pendingDependencyLink?.draggedFeature || null} + targetFeature={pendingDependencyLink?.targetFeature || null} + onLink={handleCreateDependencyLink} + /> + {/* Edit Feature Dialog */} - {/* Merge Worktree Dialog */} - f.branchName === selectedWorktreeForAction.branch).length - : 0 - } - onMerged={(mergedWorktree) => { - // Reset features that were assigned to the merged worktree (by branch) - hookFeatures.forEach((feature) => { - if (feature.branchName === mergedWorktree.branch) { - // Reset the feature's branch assignment - update both local state and persist - const updates = { - branchName: null as unknown as string | undefined, - }; - updateFeature(feature.id, updates); - persistFeatureUpdate(feature.id, updates); - } - }); - - setWorktreeRefreshKey((k) => k + 1); - setSelectedWorktreeForAction(null); - }} + onConfirm={handleConfirmResolveConflicts} /> {/* Commit Worktree Dialog */} 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 31863fb5..ea078dd6 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 @@ -1,6 +1,6 @@ // @ts-nocheck -import React, { memo, useLayoutEffect, useState } from 'react'; -import { useDraggable } from '@dnd-kit/core'; +import React, { memo, useLayoutEffect, useState, useCallback } from 'react'; +import { useDraggable, useDroppable } from '@dnd-kit/core'; import { cn } from '@/lib/utils'; import { Card, CardContent } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; @@ -123,12 +123,39 @@ export const KanbanCard = memo(function KanbanCard({ (feature.status === 'backlog' || feature.status === 'waiting_approval' || feature.status === 'verified' || + feature.status.startsWith('pipeline_') || (feature.status === 'in_progress' && !isCurrentAutoTask)); - const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + const { + attributes, + listeners, + setNodeRef: setDraggableRef, + isDragging, + } = useDraggable({ id: feature.id, disabled: !isDraggable || isOverlay || isSelectionMode, }); + // Make the card a drop target for creating dependency links + // Only backlog cards can be link targets (to avoid complexity with running features) + const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode; + const { setNodeRef: setDroppableRef, isOver } = useDroppable({ + id: `card-drop-${feature.id}`, + disabled: !isDroppable, + data: { + type: 'card', + featureId: feature.id, + }, + }); + + // Combine refs for both draggable and droppable + const setNodeRef = useCallback( + (node: HTMLElement | null) => { + setDraggableRef(node); + setDroppableRef(node); + }, + [setDraggableRef, setDroppableRef] + ); + const dndStyle = { opacity: isDragging ? 0.5 : undefined, }; @@ -141,7 +168,9 @@ export const KanbanCard = memo(function KanbanCard({ const wrapperClasses = cn( 'relative select-none outline-none touch-none transition-transform duration-200 ease-out', getCursorClass(isOverlay, isDraggable, isSelectable), - isOverlay && isLifted && 'scale-105 rotate-1 z-50' + isOverlay && isLifted && 'scale-105 rotate-1 z-50', + // Visual feedback when another card is being dragged over this one + isOver && !isDragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-[1.02]' ); const isInteractive = !isDragging && !isOverlay; diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx index cca4e474..c8b9e430 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx @@ -23,7 +23,6 @@ interface ColumnDef { /** * Default column definitions for the list view - * Only showing title column with full width for a cleaner, more spacious layout */ export const LIST_COLUMNS: ColumnDef[] = [ { @@ -34,6 +33,14 @@ export const LIST_COLUMNS: ColumnDef[] = [ minWidth: 'min-w-0', align: 'left', }, + { + id: 'priority', + label: '', + sortable: true, + width: 'w-18', + minWidth: 'min-w-[16px]', + align: 'center', + }, ]; export interface ListHeaderProps { @@ -117,6 +124,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1', column.width, column.minWidth, + column.width !== 'flex-1' && 'shrink-0', column.align === 'center' && 'justify-center', column.align === 'right' && 'justify-end', isSorted && 'text-foreground', @@ -141,6 +149,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column 'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground', column.width, column.minWidth, + column.width !== 'flex-1' && 'shrink-0', column.align === 'center' && 'justify-center', column.align === 'right' && 'justify-end', column.className diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx index f3877906..a3d10eb7 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx @@ -281,7 +281,7 @@ export const ListRow = memo(function ListRow({
+ {/* Priority column */} +
+ {feature.priority ? ( + + {feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'} + + ) : ( + - + )} +
+ {/* Actions column */}
diff --git a/apps/ui/src/components/views/board-view/dialogs/dependency-link-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/dependency-link-dialog.tsx new file mode 100644 index 00000000..152e6702 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/dependency-link-dialog.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react'; +import type { Feature } from '@/store/app-store'; +import { cn } from '@/lib/utils'; + +export type DependencyLinkType = 'parent' | 'child'; + +interface DependencyLinkDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + draggedFeature: Feature | null; + targetFeature: Feature | null; + onLink: (linkType: DependencyLinkType) => void; +} + +export function DependencyLinkDialog({ + open, + onOpenChange, + draggedFeature, + targetFeature, + onLink, +}: DependencyLinkDialogProps) { + if (!draggedFeature || !targetFeature) return null; + + // Check if a dependency relationship already exists + const draggedDependsOnTarget = + Array.isArray(draggedFeature.dependencies) && + draggedFeature.dependencies.includes(targetFeature.id); + const targetDependsOnDragged = + Array.isArray(targetFeature.dependencies) && + targetFeature.dependencies.includes(draggedFeature.id); + const existingLink = draggedDependsOnTarget || targetDependsOnDragged; + + return ( + + + + + + Link Features + + + Create a dependency relationship between these features. + + + +
+ {/* Dragged feature */} +
+
Dragged Feature
+
+ {draggedFeature.description} +
+
{draggedFeature.category}
+
+ + {/* Arrow indicating direction */} +
+ +
+ + {/* Target feature */} +
+
Target Feature
+
+ {targetFeature.description} +
+
{targetFeature.category}
+
+ + {/* Existing link warning */} + {existingLink && ( +
+ {draggedDependsOnTarget + ? 'The dragged feature already depends on the target feature.' + : 'The target feature already depends on the dragged feature.'} +
+ )} +
+ + + {/* Set as Parent - top */} + + {/* Set as Child - middle */} + + {/* Cancel - bottom */} + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 84027daf..419f1004 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -4,8 +4,12 @@ export { BacklogPlanDialog } from './backlog-plan-dialog'; export { CompletedFeaturesModal } from './completed-features-modal'; export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog'; export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog'; +export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog'; export { EditFeatureDialog } from './edit-feature-dialog'; export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog'; +export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog'; export { PlanApprovalDialog } from './plan-approval-dialog'; export { MassEditDialog } from './mass-edit-dialog'; +export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog'; +export { PushToRemoteDialog } from './push-to-remote-dialog'; export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog'; diff --git a/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx index e5a255f3..7bb1440a 100644 --- a/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx @@ -8,58 +8,81 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; -import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react'; +import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; +import { BranchAutocomplete } from '@/components/ui/branch-autocomplete'; +import type { WorktreeInfo, BranchInfo, MergeConflictInfo } from '../worktree-panel/types'; -interface WorktreeInfo { - path: string; - branch: string; - isMain: boolean; - hasChanges?: boolean; - changedFilesCount?: number; -} +export type { MergeConflictInfo } from '../worktree-panel/types'; interface MergeWorktreeDialogProps { open: boolean; onOpenChange: (open: boolean) => void; projectPath: string; worktree: WorktreeInfo | null; - onMerged: (mergedWorktree: WorktreeInfo) => void; - /** Number of features assigned to this worktree's branch */ - affectedFeatureCount?: number; + /** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */ + onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void; + onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; } -type DialogStep = 'confirm' | 'verify'; - export function MergeWorktreeDialog({ open, onOpenChange, projectPath, worktree, onMerged, - affectedFeatureCount = 0, + onCreateConflictResolutionFeature, }: MergeWorktreeDialogProps) { const [isLoading, setIsLoading] = useState(false); - const [step, setStep] = useState('confirm'); - const [confirmText, setConfirmText] = useState(''); + const [targetBranch, setTargetBranch] = useState('main'); + const [availableBranches, setAvailableBranches] = useState([]); + const [loadingBranches, setLoadingBranches] = useState(false); + const [deleteWorktreeAndBranch, setDeleteWorktreeAndBranch] = useState(false); + const [mergeConflict, setMergeConflict] = useState(null); + + // Fetch available branches when dialog opens + useEffect(() => { + if (open && worktree && projectPath) { + setLoadingBranches(true); + const api = getElectronAPI(); + if (api?.worktree?.listBranches) { + api.worktree + .listBranches(projectPath, false) + .then((result) => { + if (result.success && result.result?.branches) { + // Filter out the source branch (can't merge into itself) and remote branches + const branches = result.result.branches + .filter((b: BranchInfo) => !b.isRemote && b.name !== worktree.branch) + .map((b: BranchInfo) => b.name); + setAvailableBranches(branches); + } + }) + .catch((err) => { + console.error('Failed to fetch branches:', err); + }) + .finally(() => { + setLoadingBranches(false); + }); + } else { + setLoadingBranches(false); + } + } + }, [open, worktree, projectPath]); // Reset state when dialog opens useEffect(() => { if (open) { setIsLoading(false); - setStep('confirm'); - setConfirmText(''); + setTargetBranch('main'); + setDeleteWorktreeAndBranch(false); + setMergeConflict(null); } }, [open]); - const handleProceedToVerify = () => { - setStep('verify'); - }; - const handleMerge = async () => { if (!worktree) return; @@ -71,96 +94,151 @@ export function MergeWorktreeDialog({ return; } - // Pass branchName and worktreePath directly to the API - const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path); + // Pass branchName, worktreePath, targetBranch, and options to the API + const result = await api.worktree.mergeFeature( + projectPath, + worktree.branch, + worktree.path, + targetBranch, + { deleteWorktreeAndBranch } + ); if (result.success) { - toast.success('Branch merged to main', { - description: `Branch "${worktree.branch}" has been merged and cleaned up`, - }); - onMerged(worktree); + const description = deleteWorktreeAndBranch + ? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted` + : `Branch "${worktree.branch}" has been merged into "${targetBranch}"`; + toast.success(`Branch merged to ${targetBranch}`, { description }); + onMerged(worktree, deleteWorktreeAndBranch); onOpenChange(false); } else { - toast.error('Failed to merge branch', { - description: result.error, - }); + // Check if the error indicates merge conflicts + const errorMessage = result.error || ''; + const hasConflicts = + errorMessage.toLowerCase().includes('conflict') || + errorMessage.toLowerCase().includes('merge failed') || + errorMessage.includes('CONFLICT'); + + if (hasConflicts && onCreateConflictResolutionFeature) { + // Set merge conflict state to show the conflict resolution UI + setMergeConflict({ + sourceBranch: worktree.branch, + targetBranch: targetBranch, + targetWorktreePath: projectPath, // The merge happens in the target branch's worktree + }); + toast.error('Merge conflicts detected', { + description: 'The merge has conflicts that need to be resolved manually.', + }); + } else { + toast.error('Failed to merge branch', { + description: result.error, + }); + } } } catch (err) { - toast.error('Failed to merge branch', { - description: err instanceof Error ? err.message : 'Unknown error', - }); + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + // Check if the error indicates merge conflicts + const hasConflicts = + errorMessage.toLowerCase().includes('conflict') || + errorMessage.toLowerCase().includes('merge failed') || + errorMessage.includes('CONFLICT'); + + if (hasConflicts && onCreateConflictResolutionFeature) { + setMergeConflict({ + sourceBranch: worktree.branch, + targetBranch: targetBranch, + targetWorktreePath: projectPath, + }); + toast.error('Merge conflicts detected', { + description: 'The merge has conflicts that need to be resolved manually.', + }); + } else { + toast.error('Failed to merge branch', { + description: errorMessage, + }); + } } finally { setIsLoading(false); } }; + const handleCreateConflictResolutionFeature = () => { + if (mergeConflict && onCreateConflictResolutionFeature) { + onCreateConflictResolutionFeature(mergeConflict); + onOpenChange(false); + } + }; + if (!worktree) return null; - const confirmationWord = 'merge'; - const isConfirmValid = confirmText.toLowerCase() === confirmationWord; - - // First step: Show what will happen and ask for confirmation - if (step === 'confirm') { + // Show conflict resolution UI if there are merge conflicts + if (mergeConflict) { return ( - - Merge to Main + + Merge Conflicts Detected -
+
- Merge branch{' '} - {worktree.branch} into - main? + There are conflicts when merging{' '} + + {mergeConflict.sourceBranch} + {' '} + into{' '} + + {mergeConflict.targetBranch} + + . -
- This will: -
    -
  • Merge the branch into the main branch
  • -
  • Remove the worktree directory
  • -
  • Delete the branch
  • -
+
+ + + The merge could not be completed automatically. You can create a feature task to + resolve the conflicts in the{' '} + + {mergeConflict.targetBranch} + {' '} + branch. +
- {worktree.hasChanges && ( -
- - - This worktree has {worktree.changedFilesCount} uncommitted change(s). Please - commit or discard them before merging. - -
- )} - - {affectedFeatureCount > 0 && ( -
- - - {affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '} - {affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will - be unassigned after merge. - -
- )} +
+

+ This will create a high-priority feature task that will: +

+
    +
  • + Resolve merge conflicts in the{' '} + + {mergeConflict.targetBranch} + {' '} + branch +
  • +
  • Ensure the code compiles and tests pass
  • +
  • Complete the merge automatically
  • +
+
- + @@ -168,52 +246,86 @@ export function MergeWorktreeDialog({ ); } - // Second step: Type confirmation return ( - - Confirm Merge + + Merge Branch
-
- - - This action cannot be undone. The branch{' '} - {worktree.branch} will be - permanently deleted after merging. - -
+ + Merge {worktree.branch}{' '} + into: +
-
+ + {worktree.hasChanges && ( +
+ + + This worktree has {worktree.changedFilesCount} uncommitted change(s). Please + commit or discard them before merging. + +
+ )}
+
+ setDeleteWorktreeAndBranch(checked === true)} + /> + +
+ + {deleteWorktreeAndBranch && ( +
+ + + The worktree and branch will be permanently deleted. Any features assigned to this + branch will be unassigned. + +
+ )} + - diff --git a/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx new file mode 100644 index 00000000..a4bd44f4 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx @@ -0,0 +1,303 @@ +import { useState, useEffect } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { toast } from 'sonner'; +import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; +} + +interface RemoteBranch { + name: string; + fullRef: string; +} + +interface RemoteInfo { + name: string; + url: string; + branches: RemoteBranch[]; +} + +const logger = createLogger('PullResolveConflictsDialog'); + +interface PullResolveConflictsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + worktree: WorktreeInfo | null; + onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void; +} + +export function PullResolveConflictsDialog({ + open, + onOpenChange, + worktree, + onConfirm, +}: PullResolveConflictsDialogProps) { + const [remotes, setRemotes] = useState([]); + const [selectedRemote, setSelectedRemote] = useState(''); + const [selectedBranch, setSelectedBranch] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + + // Fetch remotes when dialog opens + useEffect(() => { + if (open && worktree) { + fetchRemotes(); + } + }, [open, worktree]); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setSelectedRemote(''); + setSelectedBranch(''); + setError(null); + } + }, [open]); + + // Auto-select default remote and branch when remotes are loaded + useEffect(() => { + if (remotes.length > 0 && !selectedRemote) { + // Default to 'origin' if available, otherwise first remote + const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0]; + setSelectedRemote(defaultRemote.name); + + // Try to select a matching branch name or default to main/master + if (defaultRemote.branches.length > 0 && worktree) { + const matchingBranch = defaultRemote.branches.find((b) => b.name === worktree.branch); + const mainBranch = defaultRemote.branches.find( + (b) => b.name === 'main' || b.name === 'master' + ); + const defaultBranch = matchingBranch || mainBranch || defaultRemote.branches[0]; + setSelectedBranch(defaultBranch.fullRef); + } + } + }, [remotes, selectedRemote, worktree]); + + // Update selected branch when remote changes + useEffect(() => { + if (selectedRemote && remotes.length > 0 && worktree) { + const remote = remotes.find((r) => r.name === selectedRemote); + if (remote && remote.branches.length > 0) { + // Try to select a matching branch name or default to main/master + const matchingBranch = remote.branches.find((b) => b.name === worktree.branch); + const mainBranch = remote.branches.find((b) => b.name === 'main' || b.name === 'master'); + const defaultBranch = matchingBranch || mainBranch || remote.branches[0]; + setSelectedBranch(defaultBranch.fullRef); + } else { + setSelectedBranch(''); + } + } + }, [selectedRemote, remotes, worktree]); + + const fetchRemotes = async () => { + if (!worktree) return; + + setIsLoading(true); + setError(null); + + try { + const api = getHttpApiClient(); + const result = await api.worktree.listRemotes(worktree.path); + + if (result.success && result.result) { + setRemotes(result.result.remotes); + if (result.result.remotes.length === 0) { + setError('No remotes found in this repository'); + } + } else { + setError(result.error || 'Failed to fetch remotes'); + } + } catch (err) { + logger.error('Failed to fetch remotes:', err); + setError('Failed to fetch remotes'); + } finally { + setIsLoading(false); + } + }; + + const handleRefresh = async () => { + if (!worktree) return; + + setIsRefreshing(true); + setError(null); + + try { + const api = getHttpApiClient(); + const result = await api.worktree.listRemotes(worktree.path); + + if (result.success && result.result) { + setRemotes(result.result.remotes); + toast.success('Remotes refreshed'); + } else { + toast.error(result.error || 'Failed to refresh remotes'); + } + } catch (err) { + logger.error('Failed to refresh remotes:', err); + toast.error('Failed to refresh remotes'); + } finally { + setIsRefreshing(false); + } + }; + + const handleConfirm = () => { + if (!worktree || !selectedBranch) return; + onConfirm(worktree, selectedBranch); + onOpenChange(false); + }; + + const selectedRemoteData = remotes.find((r) => r.name === selectedRemote); + const branches = selectedRemoteData?.branches || []; + + return ( + + + + + + Pull & Resolve Conflicts + + + Select a remote branch to pull from and resolve conflicts with{' '} + + {worktree?.branch || 'current branch'} + + + + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+
+ + {error} +
+ +
+ ) : ( +
+
+
+ + +
+ +
+ +
+ + + {selectedRemote && branches.length === 0 && ( +

No branches found for this remote

+ )} +
+ + {selectedBranch && ( +
+

+ This will create a feature task to pull from{' '} + {selectedBranch} into{' '} + {worktree?.branch} and resolve + any merge conflicts. +

+
+ )} +
+ )} + + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx new file mode 100644 index 00000000..4e02b4e1 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx @@ -0,0 +1,242 @@ +import { useState, useEffect } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { toast } from 'sonner'; +import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import type { WorktreeInfo } from '../worktree-panel/types'; + +interface RemoteInfo { + name: string; + url: string; +} + +const logger = createLogger('PushToRemoteDialog'); + +interface PushToRemoteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + worktree: WorktreeInfo | null; + onConfirm: (worktree: WorktreeInfo, remote: string) => void; +} + +export function PushToRemoteDialog({ + open, + onOpenChange, + worktree, + onConfirm, +}: PushToRemoteDialogProps) { + const [remotes, setRemotes] = useState([]); + const [selectedRemote, setSelectedRemote] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + + // Fetch remotes when dialog opens + useEffect(() => { + if (open && worktree) { + fetchRemotes(); + } + }, [open, worktree]); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setSelectedRemote(''); + setError(null); + } + }, [open]); + + // Auto-select default remote when remotes are loaded + useEffect(() => { + if (remotes.length > 0 && !selectedRemote) { + // Default to 'origin' if available, otherwise first remote + const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0]; + setSelectedRemote(defaultRemote.name); + } + }, [remotes, selectedRemote]); + + const fetchRemotes = async () => { + if (!worktree) return; + + setIsLoading(true); + setError(null); + + try { + const api = getHttpApiClient(); + const result = await api.worktree.listRemotes(worktree.path); + + if (result.success && result.result) { + // Extract just the remote info (name and URL), not the branches + const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({ + name: r.name, + url: r.url, + })); + setRemotes(remoteInfos); + if (remoteInfos.length === 0) { + setError('No remotes found in this repository. Please add a remote first.'); + } + } else { + setError(result.error || 'Failed to fetch remotes'); + } + } catch (err) { + logger.error('Failed to fetch remotes:', err); + setError('Failed to fetch remotes'); + } finally { + setIsLoading(false); + } + }; + + const handleRefresh = async () => { + if (!worktree) return; + + setIsRefreshing(true); + setError(null); + + try { + const api = getHttpApiClient(); + const result = await api.worktree.listRemotes(worktree.path); + + if (result.success && result.result) { + const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({ + name: r.name, + url: r.url, + })); + setRemotes(remoteInfos); + toast.success('Remotes refreshed'); + } else { + toast.error(result.error || 'Failed to refresh remotes'); + } + } catch (err) { + logger.error('Failed to refresh remotes:', err); + toast.error('Failed to refresh remotes'); + } finally { + setIsRefreshing(false); + } + }; + + const handleConfirm = () => { + if (!worktree || !selectedRemote) return; + onConfirm(worktree, selectedRemote); + onOpenChange(false); + }; + + return ( + + + + + + Push New Branch to Remote + + + new + + + + Push{' '} + + {worktree?.branch || 'current branch'} + {' '} + to a remote repository for the first time. + + + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+
+ + {error} +
+ +
+ ) : ( +
+
+
+ + +
+ +
+ + {selectedRemote && ( +
+

+ This will create a new remote branch{' '} + + {selectedRemote}/{worktree?.branch} + {' '} + and set up tracking. +

+
+ )} +
+ )} + + + + + +
+
+ ); +} 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 3e94c08a..a2caef8a 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 @@ -92,6 +92,7 @@ export function useBoardActions({ skipVerificationInAutoMode, isPrimaryWorktreeBranch, getPrimaryWorktreeBranch, + getAutoModeState, } = useAppStore(); const autoMode = useAutoMode(); @@ -485,10 +486,22 @@ export function useBoardActions({ const handleStartImplementation = useCallback( async (feature: Feature) => { - if (!autoMode.canStartNewTask) { + // Check capacity for the feature's specific worktree, not the current view + const featureBranchName = feature.branchName ?? null; + const featureWorktreeState = currentProject + ? getAutoModeState(currentProject.id, featureBranchName) + : null; + const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency; + const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0; + const canStartInWorktree = featureRunningCount < featureMaxConcurrency; + + if (!canStartInWorktree) { + const worktreeDesc = featureBranchName + ? `worktree "${featureBranchName}"` + : 'main worktree'; toast.error('Concurrency limit reached', { - description: `You can only have ${autoMode.maxConcurrency} task${ - autoMode.maxConcurrency > 1 ? 's' : '' + description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${ + featureMaxConcurrency > 1 ? 's' : '' } running at a time. Wait for a task to complete or increase the limit.`, }); return false; @@ -552,6 +565,8 @@ export function useBoardActions({ updateFeature, persistFeatureUpdate, handleRunFeature, + currentProject, + getAutoModeState, ] ); diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts index 466d7cca..25d0451a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -8,6 +8,11 @@ import { COLUMNS, ColumnId } from '../constants'; const logger = createLogger('BoardDragDrop'); +export interface PendingDependencyLink { + draggedFeature: Feature; + targetFeature: Feature; +} + interface UseBoardDragDropProps { features: Feature[]; currentProject: { path: string; id: string } | null; @@ -24,7 +29,10 @@ export function useBoardDragDrop({ handleStartImplementation, }: UseBoardDragDropProps) { const [activeFeature, setActiveFeature] = useState(null); - const { moveFeature } = useAppStore(); + const [pendingDependencyLink, setPendingDependencyLink] = useState( + null + ); + const { moveFeature, updateFeature } = useAppStore(); // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side // at execution time based on feature.branchName @@ -40,6 +48,11 @@ export function useBoardDragDrop({ [features] ); + // Clear pending dependency link + const clearPendingDependencyLink = useCallback(() => { + setPendingDependencyLink(null); + }, []); + const handleDragEnd = useCallback( async (event: DragEndEvent) => { const { active, over } = event; @@ -57,6 +70,85 @@ export function useBoardDragDrop({ // Check if this is a running task (non-skipTests, TDD) const isRunningTask = runningAutoTasks.includes(featureId); + // Check if dropped on another card (for creating dependency links) + if (overId.startsWith('card-drop-')) { + const cardData = over.data.current as { + type: string; + featureId: string; + }; + + if (cardData?.type === 'card') { + const targetFeatureId = cardData.featureId; + + // Don't link to self + if (targetFeatureId === featureId) { + return; + } + + const targetFeature = features.find((f) => f.id === targetFeatureId); + if (!targetFeature) return; + + // Only allow linking backlog features (both must be in backlog) + if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') { + toast.error('Cannot link features', { + description: 'Both features must be in the backlog to create a dependency link.', + }); + return; + } + + // Set pending dependency link to trigger dialog + setPendingDependencyLink({ + draggedFeature, + targetFeature, + }); + return; + } + } + + // Check if dropped on a worktree tab + if (overId.startsWith('worktree-drop-')) { + // Handle dropping on a worktree - change the feature's branchName + const worktreeData = over.data.current as { + type: string; + branch: string; + path: string; + isMain: boolean; + }; + + if (worktreeData?.type === 'worktree') { + // Don't allow moving running tasks to a different worktree + if (isRunningTask) { + logger.debug('Cannot move running feature to different worktree'); + toast.error('Cannot move feature', { + description: 'This feature is currently running and cannot be moved.', + }); + return; + } + + const targetBranch = worktreeData.branch; + const currentBranch = draggedFeature.branchName; + + // If already on the same branch, nothing to do + if (currentBranch === targetBranch) { + return; + } + + // For main worktree, set branchName to undefined/null to indicate it should use main + // For other worktrees, set branchName to the target branch + const newBranchName = worktreeData.isMain ? undefined : targetBranch; + + // Update feature's branchName + updateFeature(featureId, { branchName: newBranchName }); + await persistFeatureUpdate(featureId, { branchName: newBranchName }); + + const branchDisplay = worktreeData.isMain ? targetBranch : targetBranch; + toast.success('Feature moved to branch', { + description: `Moved to ${branchDisplay}: ${draggedFeature.description.slice(0, 40)}${draggedFeature.description.length > 40 ? '...' : ''}`, + }); + return; + } + } + // Determine if dragging is allowed based on status and skipTests // - Backlog items can always be dragged // - waiting_approval items can always be dragged (to allow manual verification via drag) @@ -205,12 +297,21 @@ export function useBoardDragDrop({ } } }, - [features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation] + [ + features, + runningAutoTasks, + moveFeature, + updateFeature, + persistFeatureUpdate, + handleStartImplementation, + ] ); return { activeFeature, handleDragStart, handleDragEnd, + pendingDependencyLink, + clearPendingDependencyLink, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts index 1a7eda53..df352b01 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts @@ -1,6 +1,5 @@ import { useEffect, useRef } from 'react'; import { getElectronAPI } from '@/lib/electron'; -import { useAppStore } from '@/store/app-store'; import { createLogger } from '@automaker/utils/logger'; const logger = createLogger('BoardEffects'); @@ -65,37 +64,8 @@ export function useBoardEffects({ }; }, [specCreatingForProject, setSpecCreatingForProject]); - // Sync running tasks from electron backend on mount - useEffect(() => { - if (!currentProject) return; - - const syncRunningTasks = async () => { - try { - const api = getElectronAPI(); - if (!api?.autoMode?.status) return; - - const status = await api.autoMode.status(currentProject.path); - if (status.success) { - const projectId = currentProject.id; - const { clearRunningTasks, addRunningTask } = useAppStore.getState(); - - if (status.runningFeatures) { - logger.info('Syncing running tasks from backend:', status.runningFeatures); - - clearRunningTasks(projectId); - - status.runningFeatures.forEach((featureId: string) => { - addRunningTask(projectId, featureId); - }); - } - } - } catch (error) { - logger.error('Failed to sync running tasks:', error); - } - }; - - syncRunningTasks(); - }, [currentProject]); + // Note: Running tasks sync is now handled by useAutoMode hook in BoardView + // which correctly handles worktree/branch scoping. // Check which features have context files useEffect(() => { 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 34616875..ebdd5034 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 @@ -123,7 +123,9 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { } else if (event.type === 'auto_mode_error') { // Remove from running tasks if (event.featureId) { - removeRunningTask(eventProjectId, event.featureId); + const eventBranchName = + 'branchName' in event && event.branchName !== undefined ? event.branchName : null; + removeRunningTask(eventProjectId, eventBranchName, event.featureId); } // Show error toast diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 4b642ece..07b58277 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ReactNode, UIEvent, RefObject } from 'react'; -import { DndContext, DragOverlay } from '@dnd-kit/core'; +import { useMemo } from 'react'; +import { DragOverlay } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Button } from '@/components/ui/button'; import { KanbanColumn, KanbanCard, EmptyStateCard } from './components'; @@ -11,10 +10,6 @@ import { getColumnsWithPipeline, type ColumnId } from './constants'; import type { PipelineConfig } from '@automaker/types'; import { cn } from '@/lib/utils'; interface KanbanBoardProps { - sensors: any; - collisionDetectionStrategy: (args: any) => any; - onDragStart: (event: any) => void; - onDragEnd: (event: any) => void; activeFeature: Feature | null; getColumnFeatures: (columnId: ColumnId) => Feature[]; backgroundImageStyle: React.CSSProperties; @@ -259,10 +254,6 @@ function VirtualizedList({ } export function KanbanBoard({ - sensors, - collisionDetectionStrategy, - onDragStart, - onDragEnd, activeFeature, getColumnFeatures, backgroundImageStyle, @@ -319,131 +310,99 @@ export function KanbanBoard({ )} style={backgroundImageStyle} > - -
- {columns.map((column) => { - const columnFeatures = getColumnFeatures(column.id as ColumnId); - return ( - - {({ - contentRef, - onScroll, - itemIds, - visibleItems, - totalHeight, - offsetTop, - startIndex, - shouldVirtualize, - registerItem, - }) => ( - - {columnFeatures.length > 0 && ( - - )} +
+ {columns.map((column) => { + const columnFeatures = getColumnFeatures(column.id as ColumnId); + return ( + + {({ + contentRef, + onScroll, + itemIds, + visibleItems, + totalHeight, + offsetTop, + startIndex, + shouldVirtualize, + registerItem, + }) => ( + + {columnFeatures.length > 0 && ( -
- ) : column.id === 'backlog' ? ( -
- - -
- ) : column.id === 'waiting_approval' ? ( + )} +
+ ) : column.id === 'backlog' ? ( +
+ + - ) : column.id === 'in_progress' ? ( - - ) : column.isPipelineStep ? ( - - ) : undefined - } - footerAction={ - column.id === 'backlog' ? ( - - ) : undefined - } - > - {(() => { - const reduceEffects = shouldVirtualize; - const effectiveCardOpacity = reduceEffects - ? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT) - : backgroundSettings.cardOpacity; - const effectiveGlassmorphism = - backgroundSettings.cardGlassmorphism && !reduceEffects; +
+ ) : column.id === 'waiting_approval' ? ( + + ) : column.id === 'in_progress' ? ( + + ) : column.isPipelineStep ? ( + + ) : undefined + } + footerAction={ + column.id === 'backlog' ? ( + + ) : undefined + } + > + {(() => { + const reduceEffects = shouldVirtualize; + const effectiveCardOpacity = reduceEffects + ? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT) + : backgroundSettings.cardOpacity; + const effectiveGlassmorphism = + backgroundSettings.cardGlassmorphism && !reduceEffects; - return ( - - {/* Empty state card when column has no features */} - {columnFeatures.length === 0 && !isDragging && ( - - )} - {shouldVirtualize ? ( -
-
- {visibleItems.map((feature, index) => { - const absoluteIndex = startIndex + index; - let shortcutKey: string | undefined; - if (column.id === 'in_progress' && absoluteIndex < 10) { - shortcutKey = - absoluteIndex === 9 ? '0' : String(absoluteIndex + 1); + return ( + + {/* Empty state card when column has no features */} + {columnFeatures.length === 0 && !isDragging && ( + - onEdit(feature)} - onDelete={() => onDelete(feature.id)} - onViewOutput={() => onViewOutput(feature)} - onVerify={() => onVerify(feature)} - onResume={() => onResume(feature)} - onForceStop={() => onForceStop(feature)} - onManualVerify={() => onManualVerify(feature)} - onMoveBackToInProgress={() => - onMoveBackToInProgress(feature) - } - onFollowUp={() => onFollowUp(feature)} - onComplete={() => onComplete(feature)} - onImplement={() => onImplement(feature)} - onViewPlan={() => onViewPlan(feature)} - onApprovePlan={() => onApprovePlan(feature)} - onSpawnTask={() => onSpawnTask?.(feature)} - hasContext={featuresWithContext.has(feature.id)} - isCurrentAutoTask={runningAutoTasks.includes(feature.id)} - shortcutKey={shortcutKey} - opacity={effectiveCardOpacity} - glassmorphism={effectiveGlassmorphism} - cardBorderEnabled={backgroundSettings.cardBorderEnabled} - cardBorderOpacity={backgroundSettings.cardBorderOpacity} - reduceEffects={reduceEffects} - isSelectionMode={isSelectionMode} - selectionTarget={selectionTarget} - isSelected={selectedFeatureIds.has(feature.id)} - onToggleSelect={() => - onToggleFeatureSelection?.(feature.id) - } - /> -
- ); - })} -
+ : undefined + } + /> + )} + {shouldVirtualize ? ( +
+
+ {visibleItems.map((feature, index) => { + const absoluteIndex = startIndex + index; + let shortcutKey: string | undefined; + if (column.id === 'in_progress' && absoluteIndex < 10) { + shortcutKey = + absoluteIndex === 9 ? '0' : String(absoluteIndex + 1); + } + return ( +
+ onEdit(feature)} + onDelete={() => onDelete(feature.id)} + onViewOutput={() => onViewOutput(feature)} + onVerify={() => onVerify(feature)} + onResume={() => onResume(feature)} + onForceStop={() => onForceStop(feature)} + onManualVerify={() => onManualVerify(feature)} + onMoveBackToInProgress={() => onMoveBackToInProgress(feature)} + onFollowUp={() => onFollowUp(feature)} + onComplete={() => onComplete(feature)} + onImplement={() => onImplement(feature)} + onViewPlan={() => onViewPlan(feature)} + onApprovePlan={() => onApprovePlan(feature)} + onSpawnTask={() => onSpawnTask?.(feature)} + hasContext={featuresWithContext.has(feature.id)} + isCurrentAutoTask={runningAutoTasks.includes(feature.id)} + shortcutKey={shortcutKey} + opacity={effectiveCardOpacity} + glassmorphism={effectiveGlassmorphism} + cardBorderEnabled={backgroundSettings.cardBorderEnabled} + cardBorderOpacity={backgroundSettings.cardBorderOpacity} + reduceEffects={reduceEffects} + isSelectionMode={isSelectionMode} + selectionTarget={selectionTarget} + isSelected={selectedFeatureIds.has(feature.id)} + onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} + /> +
+ ); + })}
- ) : ( - columnFeatures.map((feature, index) => { - let shortcutKey: string | undefined; - if (column.id === 'in_progress' && index < 10) { - shortcutKey = index === 9 ? '0' : String(index + 1); - } - return ( - onEdit(feature)} - onDelete={() => onDelete(feature.id)} - onViewOutput={() => onViewOutput(feature)} - onVerify={() => onVerify(feature)} - onResume={() => onResume(feature)} - onForceStop={() => onForceStop(feature)} - onManualVerify={() => onManualVerify(feature)} - onMoveBackToInProgress={() => onMoveBackToInProgress(feature)} - onFollowUp={() => onFollowUp(feature)} - onComplete={() => onComplete(feature)} - onImplement={() => onImplement(feature)} - onViewPlan={() => onViewPlan(feature)} - onApprovePlan={() => onApprovePlan(feature)} - onSpawnTask={() => onSpawnTask?.(feature)} - hasContext={featuresWithContext.has(feature.id)} - isCurrentAutoTask={runningAutoTasks.includes(feature.id)} - shortcutKey={shortcutKey} - opacity={effectiveCardOpacity} - glassmorphism={effectiveGlassmorphism} - cardBorderEnabled={backgroundSettings.cardBorderEnabled} - cardBorderOpacity={backgroundSettings.cardBorderOpacity} - reduceEffects={reduceEffects} - isSelectionMode={isSelectionMode} - selectionTarget={selectionTarget} - isSelected={selectedFeatureIds.has(feature.id)} - onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} - /> - ); - }) - )} - - ); - })()} - - )} - - ); - })} -
+
+ ) : ( + columnFeatures.map((feature, index) => { + let shortcutKey: string | undefined; + if (column.id === 'in_progress' && index < 10) { + shortcutKey = index === 9 ? '0' : String(index + 1); + } + return ( + onEdit(feature)} + onDelete={() => onDelete(feature.id)} + onViewOutput={() => onViewOutput(feature)} + onVerify={() => onVerify(feature)} + onResume={() => onResume(feature)} + onForceStop={() => onForceStop(feature)} + onManualVerify={() => onManualVerify(feature)} + onMoveBackToInProgress={() => onMoveBackToInProgress(feature)} + onFollowUp={() => onFollowUp(feature)} + onComplete={() => onComplete(feature)} + onImplement={() => onImplement(feature)} + onViewPlan={() => onViewPlan(feature)} + onApprovePlan={() => onApprovePlan(feature)} + onSpawnTask={() => onSpawnTask?.(feature)} + hasContext={featuresWithContext.has(feature.id)} + isCurrentAutoTask={runningAutoTasks.includes(feature.id)} + shortcutKey={shortcutKey} + opacity={effectiveCardOpacity} + glassmorphism={effectiveGlassmorphism} + cardBorderEnabled={backgroundSettings.cardBorderEnabled} + cardBorderOpacity={backgroundSettings.cardBorderOpacity} + reduceEffects={reduceEffects} + isSelectionMode={isSelectionMode} + selectionTarget={selectionTarget} + isSelected={selectedFeatureIds.has(feature.id)} + onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} + /> + ); + }) + )} + + ); + })()} + + )} + + ); + })} +
- - {activeFeature && ( -
- {}} - onDelete={() => {}} - onViewOutput={() => {}} - onVerify={() => {}} - onResume={() => {}} - onForceStop={() => {}} - onManualVerify={() => {}} - onMoveBackToInProgress={() => {}} - onFollowUp={() => {}} - onImplement={() => {}} - onComplete={() => {}} - onViewPlan={() => {}} - onApprovePlan={() => {}} - onSpawnTask={() => {}} - hasContext={featuresWithContext.has(activeFeature.id)} - isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)} - opacity={backgroundSettings.cardOpacity} - glassmorphism={backgroundSettings.cardGlassmorphism} - cardBorderEnabled={backgroundSettings.cardBorderEnabled} - cardBorderOpacity={backgroundSettings.cardBorderOpacity} - /> -
- )} -
- + + {activeFeature && ( +
+ {}} + onDelete={() => {}} + onViewOutput={() => {}} + onVerify={() => {}} + onResume={() => {}} + onForceStop={() => {}} + onManualVerify={() => {}} + onMoveBackToInProgress={() => {}} + onFollowUp={() => {}} + onImplement={() => {}} + onComplete={() => {}} + onViewPlan={() => {}} + onApprovePlan={() => {}} + onSpawnTask={() => {}} + hasContext={featuresWithContext.has(activeFeature.id)} + isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)} + opacity={backgroundSettings.cardOpacity} + glassmorphism={backgroundSettings.cardGlassmorphism} + cardBorderEnabled={backgroundSettings.cardBorderEnabled} + cardBorderOpacity={backgroundSettings.cardBorderOpacity} + /> +
+ )} +
); } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index f33ceba8..8ba682d9 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -27,11 +27,12 @@ import { Copy, Eye, ScrollText, + Sparkles, Terminal, SquarePlus, SplitSquareHorizontal, - Zap, Undo2, + Zap, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -51,6 +52,7 @@ interface WorktreeActionsDropdownProps { isSelected: boolean; aheadCount: number; behindCount: number; + hasRemoteBranch: boolean; isPulling: boolean; isPushing: boolean; isStartingDevServer: boolean; @@ -64,6 +66,7 @@ interface WorktreeActionsDropdownProps { onOpenChange: (open: boolean) => void; onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; + onPushNewBranch: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; @@ -73,7 +76,6 @@ interface WorktreeActionsDropdownProps { onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void; - onMerge: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void; onStartDevServer: (worktree: WorktreeInfo) => void; onStopDevServer: (worktree: WorktreeInfo) => void; @@ -81,6 +83,7 @@ interface WorktreeActionsDropdownProps { onViewDevServerLogs: (worktree: WorktreeInfo) => void; onRunInitScript: (worktree: WorktreeInfo) => void; onToggleAutoMode?: (worktree: WorktreeInfo) => void; + onMerge: (worktree: WorktreeInfo) => void; hasInitScript: boolean; } @@ -89,6 +92,7 @@ export function WorktreeActionsDropdown({ isSelected, aheadCount, behindCount, + hasRemoteBranch, isPulling, isPushing, isStartingDevServer, @@ -100,6 +104,7 @@ export function WorktreeActionsDropdown({ onOpenChange, onPull, onPush, + onPushNewBranch, onOpenInEditor, onOpenInIntegratedTerminal, onOpenInExternalTerminal, @@ -109,7 +114,6 @@ export function WorktreeActionsDropdown({ onCreatePR, onAddressPRComments, onResolveConflicts, - onMerge, onDeleteWorktree, onStartDevServer, onStopDevServer, @@ -117,6 +121,7 @@ export function WorktreeActionsDropdown({ onViewDevServerLogs, onRunInitScript, onToggleAutoMode, + onMerge, hasInitScript, }: WorktreeActionsDropdownProps) { // Get available editors for the "Open In" submenu @@ -264,14 +269,27 @@ export function WorktreeActionsDropdown({ canPerformGitOps && onPush(worktree)} - disabled={isPushing || aheadCount === 0 || !canPerformGitOps} + onClick={() => { + if (!canPerformGitOps) return; + if (!hasRemoteBranch) { + onPushNewBranch(worktree); + } else { + onPush(worktree); + } + }} + disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !canPerformGitOps} className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')} > {isPushing ? 'Pushing...' : 'Push'} {!canPerformGitOps && } - {canPerformGitOps && aheadCount > 0 && ( + {canPerformGitOps && !hasRemoteBranch && ( + + + new + + )} + {canPerformGitOps && hasRemoteBranch && aheadCount > 0 && ( {aheadCount} ahead @@ -292,27 +310,6 @@ export function WorktreeActionsDropdown({ {!canPerformGitOps && } - {!worktree.isMain && ( - - canPerformGitOps && onMerge(worktree)} - disabled={!canPerformGitOps} - className={cn( - 'text-xs text-green-600 focus:text-green-700', - !canPerformGitOps && 'opacity-50 cursor-not-allowed' - )} - > - - Merge to Main - {!canPerformGitOps && ( - - )} - - - )} {/* Open in editor - split button: click main area for default, chevron for other options */} {effectiveDefaultEditor && ( @@ -546,6 +543,26 @@ export function WorktreeActionsDropdown({ )} {!worktree.isMain && ( <> + + canPerformGitOps && onMerge(worktree)} + disabled={!canPerformGitOps} + className={cn( + 'text-xs text-green-600 focus:text-green-700', + !canPerformGitOps && 'opacity-50 cursor-not-allowed' + )} + > + + Merge Branch + {!canPerformGitOps && ( + + )} + + + onDeleteWorktree(worktree)} className="text-xs text-destructive focus:text-destructive" 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 6c05bf8c..d8a57ced 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 @@ -4,6 +4,7 @@ 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 { useDroppable } from '@dnd-kit/core'; import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; import { BranchSwitchDropdown } from './branch-switch-dropdown'; import { WorktreeActionsDropdown } from './worktree-actions-dropdown'; @@ -28,6 +29,7 @@ interface WorktreeTabProps { isStartingDevServer: boolean; aheadCount: number; behindCount: number; + hasRemoteBranch: boolean; gitRepoStatus: GitRepoStatus; /** Whether auto mode is running for this worktree */ isAutoModeRunning?: boolean; @@ -39,6 +41,7 @@ interface WorktreeTabProps { onCreateBranch: (worktree: WorktreeInfo) => void; onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; + onPushNewBranch: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; @@ -79,6 +82,7 @@ export function WorktreeTab({ isStartingDevServer, aheadCount, behindCount, + hasRemoteBranch, gitRepoStatus, isAutoModeRunning = false, onSelectWorktree, @@ -89,6 +93,7 @@ export function WorktreeTab({ onCreateBranch, onPull, onPush, + onPushNewBranch, onOpenInEditor, onOpenInIntegratedTerminal, onOpenInExternalTerminal, @@ -108,6 +113,16 @@ export function WorktreeTab({ onToggleAutoMode, hasInitScript, }: WorktreeTabProps) { + // Make the worktree tab a drop target for feature cards + const { setNodeRef, isOver } = useDroppable({ + id: `worktree-drop-${worktree.branch}`, + data: { + type: 'worktree', + branch: worktree.branch, + path: worktree.path, + isMain: worktree.isMain, + }, + }); let prBadge: JSX.Element | null = null; if (worktree.pr) { const prState = worktree.pr.state?.toLowerCase() ?? 'open'; @@ -194,7 +209,13 @@ export function WorktreeTab({ } return ( -
+
{worktree.isMain ? ( <>
); } @@ -448,6 +527,7 @@ export function WorktreePanel({ isStartingDevServer={isStartingDevServer} aheadCount={aheadCount} behindCount={behindCount} + hasRemoteBranch={hasRemoteBranch} gitRepoStatus={gitRepoStatus} isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)} onSelectWorktree={handleSelectWorktree} @@ -458,6 +538,7 @@ export function WorktreePanel({ onCreateBranch={onCreateBranch} onPull={handlePull} onPush={handlePush} + onPushNewBranch={handlePushNewBranch} onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} @@ -467,7 +548,7 @@ export function WorktreePanel({ onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} onResolveConflicts={onResolveConflicts} - onMerge={onMerge} + onMerge={handleMerge} onDeleteWorktree={onDeleteWorktree} onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} @@ -512,6 +593,7 @@ export function WorktreePanel({ isStartingDevServer={isStartingDevServer} aheadCount={aheadCount} behindCount={behindCount} + hasRemoteBranch={hasRemoteBranch} gitRepoStatus={gitRepoStatus} isAutoModeRunning={isAutoModeRunningForWorktree(worktree)} onSelectWorktree={handleSelectWorktree} @@ -522,6 +604,7 @@ export function WorktreePanel({ onCreateBranch={onCreateBranch} onPull={handlePull} onPush={handlePush} + onPushNewBranch={handlePushNewBranch} onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} @@ -531,7 +614,7 @@ export function WorktreePanel({ onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} onResolveConflicts={onResolveConflicts} - onMerge={onMerge} + onMerge={handleMerge} onDeleteWorktree={onDeleteWorktree} onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} @@ -602,6 +685,24 @@ export function WorktreePanel({ onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} /> + + {/* Push to Remote Dialog */} + + + {/* Merge Branch Dialog */} +
); } diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts index 551894ef..cc75dafe 100644 --- a/apps/ui/src/hooks/queries/use-worktrees.ts +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -160,6 +160,7 @@ interface BranchesResult { branches: BranchInfo[]; aheadCount: number; behindCount: number; + hasRemoteBranch: boolean; isGitRepo: boolean; hasCommits: boolean; } @@ -186,6 +187,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem branches: [], aheadCount: 0, behindCount: 0, + hasRemoteBranch: false, isGitRepo: false, hasCommits: false, }; @@ -195,6 +197,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem branches: [], aheadCount: 0, behindCount: 0, + hasRemoteBranch: false, isGitRepo: true, hasCommits: false, }; @@ -208,6 +211,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem branches: result.result?.branches ?? [], aheadCount: result.result?.aheadCount ?? 0, behindCount: result.result?.behindCount ?? 0, + hasRemoteBranch: result.result?.hasRemoteBranch ?? false, isGitRepo: true, hasCommits: true, }; diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index b62f6fa4..43af07a0 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -93,10 +93,12 @@ export function useAutoMode(worktree?: WorktreeInfo) { })) ); - // Derive branchName from worktree: main worktree uses null, feature worktrees use their branch + // Derive branchName from worktree: + // If worktree is provided, use its branch name (even for main worktree, as it might be on a feature branch) + // If not provided, default to null (main worktree default) const branchName = useMemo(() => { if (!worktree) return null; - return worktree.isMain ? null : worktree.branch; + return worktree.isMain ? null : worktree.branch || null; }, [worktree]); // Helper to look up project ID from path @@ -155,7 +157,13 @@ export function useAutoMode(worktree?: WorktreeInfo) { logger.info( `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` ); - setAutoModeRunning(currentProject.id, branchName, backendIsRunning); + setAutoModeRunning( + currentProject.id, + branchName, + backendIsRunning, + result.maxConcurrency, + result.runningFeatures + ); setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning); } } @@ -165,7 +173,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { }; syncWithBackend(); - }, [currentProject, branchName, isAutoModeRunning, setAutoModeRunning]); + }, [currentProject, branchName, setAutoModeRunning]); // Handle auto mode events - listen globally for all projects/worktrees useEffect(() => { @@ -215,6 +223,26 @@ export function useAutoMode(worktree?: WorktreeInfo) { } break; + case 'auto_mode_resuming_features': + // Backend is resuming features from saved state + if (eventProjectId && 'features' in event && Array.isArray(event.features)) { + logger.info(`[AutoMode] Resuming ${event.features.length} feature(s) from saved state`); + // Use per-feature branchName if available, fallback to event-level branchName + event.features.forEach((feature: { id: string; branchName?: string | null }) => { + const featureBranchName = feature.branchName ?? eventBranchName; + addRunningTask(eventProjectId, featureBranchName, feature.id); + }); + } else if (eventProjectId && 'featureIds' in event && Array.isArray(event.featureIds)) { + // Fallback for older event format without per-feature branchName + logger.info( + `[AutoMode] Resuming ${event.featureIds.length} feature(s) from saved state (legacy format)` + ); + event.featureIds.forEach((featureId: string) => { + addRunningTask(eventProjectId, eventBranchName, featureId); + }); + } + break; + case 'auto_mode_stopped': // Backend stopped auto loop - update UI state { @@ -484,11 +512,16 @@ export function useAutoMode(worktree?: WorktreeInfo) { logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`); // Optimistically update UI state (backend will confirm via event) + const currentMaxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName); setAutoModeSessionForWorktree(currentProject.path, branchName, true); - setAutoModeRunning(currentProject.id, branchName, true); + setAutoModeRunning(currentProject.id, branchName, true, currentMaxConcurrency); - // Call backend to start the auto loop (backend uses stored concurrency) - const result = await api.autoMode.start(currentProject.path, branchName); + // Call backend to start the auto loop (pass current max concurrency) + const result = await api.autoMode.start( + currentProject.path, + branchName, + currentMaxConcurrency + ); if (!result.success) { // Revert UI state on failure diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 05b8d183..def64ef0 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -212,6 +212,8 @@ export function parseLocalStorageSettings(): Partial | null { claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [], activeClaudeApiProfileId: (state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null, + // Event hooks + eventHooks: state.eventHooks as GlobalSettings['eventHooks'], }; } catch (error) { logger.error('Failed to parse localStorage settings:', error); diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 903b1bda..b0da8596 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1566,15 +1566,18 @@ function createMockWorktreeAPI(): WorktreeAPI { projectPath: string, branchName: string, worktreePath: string, + targetBranch?: string, options?: object ) => { + const target = targetBranch || 'main'; console.log('[Mock] Merging feature:', { projectPath, branchName, worktreePath, + targetBranch: target, options, }); - return { success: true, mergedBranch: branchName }; + return { success: true, mergedBranch: branchName, targetBranch: target }; }, getInfo: async (projectPath: string, featureId: string) => { @@ -1684,14 +1687,15 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, - push: async (worktreePath: string, force?: boolean) => { - console.log('[Mock] Pushing worktree:', { worktreePath, force }); + push: async (worktreePath: string, force?: boolean, remote?: string) => { + const targetRemote = remote || 'origin'; + console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote }); return { success: true, result: { branch: 'feature-branch', pushed: true, - message: 'Successfully pushed to origin/feature-branch', + message: `Successfully pushed to ${targetRemote}/feature-branch`, }, }; }, @@ -1777,6 +1781,7 @@ function createMockWorktreeAPI(): WorktreeAPI { ], aheadCount: 2, behindCount: 0, + hasRemoteBranch: true, }, }; }, @@ -1793,6 +1798,26 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, + listRemotes: async (worktreePath: string) => { + console.log('[Mock] Listing remotes for:', worktreePath); + return { + success: true, + result: { + remotes: [ + { + name: 'origin', + url: 'git@github.com:example/repo.git', + branches: [ + { name: 'main', fullRef: 'origin/main' }, + { name: 'develop', fullRef: 'origin/develop' }, + { name: 'feature/example', fullRef: 'origin/feature/example' }, + ], + }, + ], + }, + }; + }, + openInEditor: async (worktreePath: string, editorCommand?: string) => { const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity'; const ANTIGRAVITY_LEGACY_COMMAND = 'agy'; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index e6292bd7..dbfddc4c 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1763,8 +1763,16 @@ export class HttpApiClient implements ElectronAPI { projectPath: string, branchName: string, worktreePath: string, + targetBranch?: string, options?: object - ) => this.post('/api/worktree/merge', { projectPath, branchName, worktreePath, options }), + ) => + this.post('/api/worktree/merge', { + projectPath, + branchName, + worktreePath, + targetBranch, + options, + }), getInfo: (projectPath: string, featureId: string) => this.post('/api/worktree/info', { projectPath, featureId }), getStatus: (projectPath: string, featureId: string) => @@ -1788,8 +1796,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/commit', { worktreePath, message }), generateCommitMessage: (worktreePath: string) => this.post('/api/worktree/generate-commit-message', { worktreePath }), - push: (worktreePath: string, force?: boolean) => - this.post('/api/worktree/push', { worktreePath, force }), + push: (worktreePath: string, force?: boolean, remote?: string) => + this.post('/api/worktree/push', { worktreePath, force, remote }), createPR: (worktreePath: string, options?: any) => this.post('/api/worktree/create-pr', { worktreePath, ...options }), getDiffs: (projectPath: string, featureId: string) => @@ -1807,6 +1815,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/list-branches', { worktreePath, includeRemote }), switchBranch: (worktreePath: string, branchName: string) => this.post('/api/worktree/switch-branch', { worktreePath, branchName }), + listRemotes: (worktreePath: string) => + this.post('/api/worktree/list-remotes', { worktreePath }), openInEditor: (worktreePath: string, editorCommand?: string) => this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), getDefaultEditor: () => this.get('/api/worktree/default-editor'), diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index f81d7bb6..5f4eadff 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -1074,7 +1074,8 @@ export interface AppActions { projectId: string, branchName: string | null, running: boolean, - maxConcurrency?: number + maxConcurrency?: number, + runningTasks?: string[] ) => void; addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; @@ -2155,10 +2156,19 @@ export const useAppStore = create()((set, get) => ({ // Auto Mode actions (per-worktree) getWorktreeKey: (projectId, branchName) => { - return `${projectId}::${branchName ?? '__main__'}`; + // Normalize 'main' to null so it matches the main worktree key + // The backend sometimes sends 'main' while the UI uses null for the main worktree + const normalizedBranch = branchName === 'main' ? null : branchName; + return `${projectId}::${normalizedBranch ?? '__main__'}`; }, - setAutoModeRunning: (projectId, branchName, running, maxConcurrency?: number) => { + setAutoModeRunning: ( + projectId: string, + branchName: string | null, + running: boolean, + maxConcurrency?: number, + runningTasks?: string[] + ) => { const worktreeKey = get().getWorktreeKey(projectId, branchName); const current = get().autoModeByWorktree; const worktreeState = current[worktreeKey] || { @@ -2175,6 +2185,7 @@ export const useAppStore = create()((set, get) => ({ isRunning: running, branchName, maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + runningTasks: runningTasks ?? worktreeState.runningTasks, }, }, }); diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index e01f3588..f98f58a9 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -219,6 +219,7 @@ export type AutoModeEvent = type: 'pipeline_step_started'; featureId: string; projectPath?: string; + branchName?: string | null; stepId: string; stepName: string; stepIndex: number; @@ -228,6 +229,7 @@ export type AutoModeEvent = type: 'pipeline_step_complete'; featureId: string; projectPath?: string; + branchName?: string | null; stepId: string; stepName: string; stepIndex: number; @@ -247,6 +249,7 @@ export type AutoModeEvent = featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; phase: 'planning' | 'action' | 'verification'; message: string; } @@ -254,6 +257,7 @@ export type AutoModeEvent = type: 'auto_mode_ultrathink_preparation'; featureId: string; projectPath?: string; + branchName?: string | null; warnings: string[]; recommendations: string[]; estimatedCost?: number; @@ -263,6 +267,7 @@ export type AutoModeEvent = type: 'plan_approval_required'; featureId: string; projectPath?: string; + branchName?: string | null; planContent: string; planningMode: 'lite' | 'spec' | 'full'; planVersion?: number; @@ -271,6 +276,7 @@ export type AutoModeEvent = type: 'plan_auto_approved'; featureId: string; projectPath?: string; + branchName?: string | null; planContent: string; planningMode: 'lite' | 'spec' | 'full'; } @@ -278,6 +284,7 @@ export type AutoModeEvent = type: 'plan_approved'; featureId: string; projectPath?: string; + branchName?: string | null; hasEdits: boolean; planVersion?: number; } @@ -285,12 +292,14 @@ export type AutoModeEvent = type: 'plan_rejected'; featureId: string; projectPath?: string; + branchName?: string | null; feedback?: string; } | { type: 'plan_revision_requested'; featureId: string; projectPath?: string; + branchName?: string | null; feedback?: string; hasEdits?: boolean; planVersion?: number; @@ -298,6 +307,7 @@ export type AutoModeEvent = | { type: 'planning_started'; featureId: string; + branchName?: string | null; mode: 'lite' | 'spec' | 'full'; message: string; } @@ -718,18 +728,25 @@ export interface FileDiffResult { } export interface WorktreeAPI { - // Merge worktree branch into main and clean up + // Merge worktree branch into a target branch (defaults to 'main') and optionally clean up mergeFeature: ( projectPath: string, branchName: string, worktreePath: string, + targetBranch?: string, options?: { squash?: boolean; message?: string; + deleteWorktreeAndBranch?: boolean; } ) => Promise<{ success: boolean; mergedBranch?: string; + targetBranch?: string; + deleted?: { + worktreeDeleted: boolean; + branchDeleted: boolean; + }; error?: string; }>; @@ -839,7 +856,8 @@ export interface WorktreeAPI { // Push a worktree branch to remote push: ( worktreePath: string, - force?: boolean + force?: boolean, + remote?: string ) => Promise<{ success: boolean; result?: { @@ -932,6 +950,7 @@ export interface WorktreeAPI { }>; aheadCount: number; behindCount: number; + hasRemoteBranch: boolean; }; error?: string; code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues @@ -952,6 +971,23 @@ export interface WorktreeAPI { code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES'; }>; + // List all remotes and their branches + listRemotes: (worktreePath: string) => Promise<{ + success: boolean; + result?: { + remotes: Array<{ + name: string; + url: string; + branches: Array<{ + name: string; + fullRef: string; + }>; + }>; + }; + error?: string; + code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; + }>; + // Open a worktree directory in the editor openInEditor: ( worktreePath: string, diff --git a/apps/ui/tests/features/list-view-priority.spec.ts b/apps/ui/tests/features/list-view-priority.spec.ts new file mode 100644 index 00000000..02afda78 --- /dev/null +++ b/apps/ui/tests/features/list-view-priority.spec.ts @@ -0,0 +1,162 @@ +/** + * List View Priority Column E2E Test + * + * Verifies that the list view shows a priority column and allows sorting by priority + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + authenticateForTests, + handleLoginScreenIfPresent, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('list-view-priority-test'); + +test.describe('List View Priority Column', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + const featuresDir = path.join(automakerDir, 'features'); + fs.mkdirSync(featuresDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + + // Create test features with different priorities + const features = [ + { + id: 'feature-high-priority', + description: 'High priority feature', + priority: 1, + status: 'backlog', + category: 'test', + createdAt: new Date().toISOString(), + }, + { + id: 'feature-medium-priority', + description: 'Medium priority feature', + priority: 2, + status: 'backlog', + category: 'test', + createdAt: new Date().toISOString(), + }, + { + id: 'feature-low-priority', + description: 'Low priority feature', + priority: 3, + status: 'backlog', + category: 'test', + createdAt: new Date().toISOString(), + }, + ]; + + // Write each feature to its own directory + for (const feature of features) { + const featureDir = path.join(featuresDir, feature.id); + fs.mkdirSync(featureDir, { recursive: true }); + fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(feature, null, 2)); + } + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: ['test'] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for e2e testing.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should display priority column in list view and allow sorting', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + + // Authenticate before navigating + await authenticateForTests(page); + await page.goto('/board'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + + // Switch to list view + await page.click('[data-testid="view-toggle-list"]'); + await page.waitForTimeout(500); + + // Verify list view is active + await expect(page.locator('[data-testid="list-view"]')).toBeVisible({ timeout: 5000 }); + + // Verify priority column header exists + await expect(page.locator('[data-testid="list-header-priority"]')).toBeVisible(); + await expect(page.locator('[data-testid="list-header-priority"]')).toContainText('Priority'); + + // Verify priority cells are displayed for our test features + await expect( + page.locator('[data-testid="list-row-priority-feature-high-priority"]') + ).toBeVisible(); + await expect( + page.locator('[data-testid="list-row-priority-feature-medium-priority"]') + ).toBeVisible(); + await expect( + page.locator('[data-testid="list-row-priority-feature-low-priority"]') + ).toBeVisible(); + + // Verify priority badges show H, M, L + const highPriorityCell = page.locator( + '[data-testid="list-row-priority-feature-high-priority"]' + ); + const mediumPriorityCell = page.locator( + '[data-testid="list-row-priority-feature-medium-priority"]' + ); + const lowPriorityCell = page.locator('[data-testid="list-row-priority-feature-low-priority"]'); + + await expect(highPriorityCell).toContainText('H'); + await expect(mediumPriorityCell).toContainText('M'); + await expect(lowPriorityCell).toContainText('L'); + + // Click on priority header to sort + await page.click('[data-testid="list-header-priority"]'); + await page.waitForTimeout(300); + + // Get all rows within the backlog group and verify they are sorted by priority + // (High priority first when sorted ascending by priority value 1, 2, 3) + const backlogGroup = page.locator('[data-testid="list-group-backlog"]'); + const rows = backlogGroup.locator('[data-testid^="list-row-feature-"]'); + + // The first row should be high priority (value 1 = lowest number = first in ascending) + const firstRow = rows.first(); + await expect(firstRow).toHaveAttribute('data-testid', 'list-row-feature-high-priority'); + + // Click again to reverse sort (descending - low priority first) + await page.click('[data-testid="list-header-priority"]'); + await page.waitForTimeout(300); + + // Now the first row should be low priority (value 3 = highest number = first in descending) + const firstRowDesc = rows.first(); + await expect(firstRowDesc).toHaveAttribute('data-testid', 'list-row-feature-low-priority'); + }); +}); diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index f9849813..550f635d 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -339,7 +339,7 @@ IMPORTANT CONTEXT (automatically injected): - When deleting a feature, identify which other features depend on it Your task is to analyze the request and produce a structured JSON plan with: -1. Features to ADD (include title, description, category, and dependencies) +1. Features to ADD (include id, title, description, category, and dependencies) 2. Features to UPDATE (specify featureId and the updates) 3. Features to DELETE (specify featureId) 4. A summary of the changes @@ -352,6 +352,7 @@ Respond with ONLY a JSON object in this exact format: { "type": "add", "feature": { + "id": "descriptive-kebab-case-id", "title": "Feature title", "description": "Feature description", "category": "feature" | "bug" | "enhancement" | "refactor", @@ -386,6 +387,8 @@ Respond with ONLY a JSON object in this exact format: \`\`\` Important rules: +- CRITICAL: For new features, always include a descriptive "id" in kebab-case (e.g., "user-authentication", "design-system-foundation") +- Dependencies must reference these exact IDs - both for existing features and new features being added in the same plan - Only include fields that need to change in updates - Ensure dependency references are valid (don't reference deleted features) - Provide clear, actionable descriptions diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 0f96cbd6..644dbc3f 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -802,6 +802,18 @@ export interface GlobalSettings { * When set, the corresponding profile's settings will be used for Claude API calls */ activeClaudeApiProfileId?: string | null; + + /** + * Per-worktree auto mode settings + * Key: "${projectId}::${branchName ?? '__main__'}" + */ + autoModeByWorktree?: Record< + string, + { + maxConcurrency: number; + branchName: string | null; + } + >; } /** @@ -1071,6 +1083,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { subagentsSources: ['user', 'project'], claudeApiProfiles: [], activeClaudeApiProfileId: null, + autoModeByWorktree: {}, }; /** Default credentials (empty strings - user must provide API keys) */ diff --git a/start-automaker.sh b/start-automaker.sh index 5d9a30a4..a2029da3 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -9,7 +9,7 @@ set -e # ============================================================================ # CONFIGURATION & CONSTANTS # ============================================================================ - +export $(grep -v '^#' .env | xargs) APP_NAME="Automaker" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HISTORY_FILE="${HOME}/.automaker_launcher_history" @@ -579,7 +579,7 @@ validate_terminal_size() { echo "${C_YELLOW}⚠${RESET} Terminal size ${term_width}x${term_height} is smaller than recommended ${MIN_TERM_WIDTH}x${MIN_TERM_HEIGHT}" echo " Some elements may not display correctly." echo "" - return 1 + return 0 fi } @@ -1154,6 +1154,7 @@ fi # Execute the appropriate command case $MODE in web) + export $(grep -v '^#' .env | xargs) export TEST_PORT="$WEB_PORT" export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT" export PORT="$SERVER_PORT" From 8dd58582996a3b5837d02590d6ddcaa8a18cffaa Mon Sep 17 00:00:00 2001 From: webdevcody Date: Tue, 20 Jan 2026 10:50:53 -0500 Subject: [PATCH 20/21] docs: add SECURITY_TODO.md outlining critical security vulnerabilities and action items - Introduced a comprehensive security audit document detailing critical command injection vulnerabilities in merge and push handlers, as well as unsafe environment variable handling in a shell script. - Provided recommendations for immediate fixes, including input validation and safer command execution practices. - Highlighted positive security findings and outlined testing recommendations for command injection prevention. --- SECURITY_TODO.md | 300 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 SECURITY_TODO.md diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md new file mode 100644 index 00000000..f12c02a3 --- /dev/null +++ b/SECURITY_TODO.md @@ -0,0 +1,300 @@ +# Security Audit Findings - v0.13.0rc Branch + +**Date:** $(date) +**Audit Type:** Git diff security review against v0.13.0rc branch +**Status:** ⚠️ Security vulnerabilities found - requires fixes before release + +## Executive Summary + +No intentionally malicious code was detected in the changes. However, several **critical security vulnerabilities** were identified that could allow command injection attacks. These must be fixed before release. + +--- + +## 🔴 Critical Security Issues + +### 1. Command Injection in Merge Handler + +**File:** `apps/server/src/routes/worktree/routes/merge.ts` +**Lines:** 43, 54, 65-66, 93 +**Severity:** CRITICAL + +**Issue:** +User-controlled inputs (`branchName`, `mergeTo`, `options?.message`) are directly interpolated into shell commands without validation, allowing command injection attacks. + +**Vulnerable Code:** + +```typescript +// Line 43 - branchName not validated +await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); + +// Line 54 - mergeTo not validated +await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath }); + +// Lines 65-66 - branchName and message not validated +const mergeCmd = options?.squash + ? `git merge --squash ${branchName}` + : `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`; + +// Line 93 - message not sanitized +await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, { + cwd: projectPath, +}); +``` + +**Attack Vector:** +An attacker could inject shell commands via branch names or commit messages: + +- Branch name: `main; rm -rf /` +- Commit message: `"; malicious_command; "` + +**Fix Required:** + +1. Validate `branchName` and `mergeTo` using `isValidBranchName()` before use +2. Sanitize commit messages or use `execGitCommand` with proper escaping +3. Replace `execAsync` template literals with `execGitCommand` array-based calls + +**Note:** `isValidBranchName` is imported but only used AFTER deletion (line 119), not before execAsync calls. + +--- + +### 2. Command Injection in Push Handler + +**File:** `apps/server/src/routes/worktree/routes/push.ts` +**Lines:** 44, 49 +**Severity:** CRITICAL + +**Issue:** +User-controlled `remote` parameter and `branchName` are directly interpolated into shell commands without validation. + +**Vulnerable Code:** + +```typescript +// Line 38 - remote defaults to 'origin' but not validated +const targetRemote = remote || 'origin'; + +// Lines 44, 49 - targetRemote and branchName not validated +await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, { + cwd: worktreePath, +}); +await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, { + cwd: worktreePath, +}); +``` + +**Attack Vector:** +An attacker could inject commands via the remote name: + +- Remote: `origin; malicious_command; #` + +**Fix Required:** + +1. Validate `targetRemote` parameter (alphanumeric + `-`, `_` only) +2. Validate `branchName` before use (even though it comes from git output) +3. Use `execGitCommand` with array arguments instead of template literals + +--- + +### 3. Unsafe Environment Variable Export in Shell Script + +**File:** `start-automaker.sh` +**Lines:** 5068, 5085 +**Severity:** CRITICAL + +**Issue:** +Unsafe parsing and export of `.env` file contents using `xargs` without proper handling of special characters. + +**Vulnerable Code:** + +```bash +export $(grep -v '^#' .env | xargs) +``` + +**Attack Vector:** +If `.env` file contains malicious content with spaces, special characters, or code, it could be executed: + +- `.env` entry: `VAR="value; malicious_command"` +- Could lead to code execution during startup + +**Fix Required:** +Replace with safer parsing method: + +```bash +# Safer approach +set -a +source <(grep -v '^#' .env | sed 's/^/export /') +set +a + +# Or even safer - validate each line +while IFS= read -r line; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "$line" ]] && continue + if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + export "${BASH_REMATCH[1]}"="${BASH_REMATCH[2]}" + fi +done < .env +``` + +--- + +## 🟡 Moderate Security Concerns + +### 4. Inconsistent Use of Secure Command Execution + +**Issue:** +The codebase has `execGitCommand()` function available (which uses array arguments and is safer), but it's not consistently used. Some places still use `execAsync` with template literals. + +**Files Affected:** + +- `apps/server/src/routes/worktree/routes/merge.ts` +- `apps/server/src/routes/worktree/routes/push.ts` + +**Recommendation:** + +- Audit all `execAsync` calls with template literals +- Replace with `execGitCommand` where possible +- Document when `execAsync` is acceptable (only with fully validated inputs) + +--- + +### 5. Missing Input Validation + +**Issues:** + +1. `targetRemote` in `push.ts` defaults to 'origin' but isn't validated +2. Commit messages in `merge.ts` aren't sanitized before use in shell commands +3. `worktreePath` validation relies on middleware but should be double-checked + +**Recommendation:** + +- Add validation functions for remote names +- Sanitize commit messages (remove shell metacharacters) +- Add defensive validation even when middleware exists + +--- + +## ✅ Positive Security Findings + +1. **No Hardcoded Credentials:** No API keys, passwords, or tokens found in the diff +2. **No Data Exfiltration:** No suspicious network requests or data transmission patterns +3. **No Backdoors:** No hidden functionality or unauthorized access patterns detected +4. **Safe Command Execution:** `execGitCommand` function properly uses array arguments in some places +5. **Environment Variable Handling:** `init-script-service.ts` properly sanitizes environment variables (lines 194-220) + +--- + +## 📋 Action Items + +### Immediate (Before Release) + +- [ ] **Fix command injection in `merge.ts`** + - [ ] Validate `branchName` with `isValidBranchName()` before line 43 + - [ ] Validate `mergeTo` with `isValidBranchName()` before line 54 + - [ ] Sanitize commit messages or use `execGitCommand` for merge commands + - [ ] Replace `execAsync` template literals with `execGitCommand` array calls + +- [ ] **Fix command injection in `push.ts`** + - [ ] Add validation function for remote names + - [ ] Validate `targetRemote` before use + - [ ] Validate `branchName` before use (defensive programming) + - [ ] Replace `execAsync` template literals with `execGitCommand` + +- [ ] **Fix shell script security issue** + - [ ] Replace unsafe `export $(grep ... | xargs)` with safer parsing + - [ ] Add validation for `.env` file contents + - [ ] Test with edge cases (spaces, special chars, quotes) + +### Short-term (Next Sprint) + +- [ ] **Audit all `execAsync` calls** + - [ ] Create inventory of all `execAsync` calls with template literals + - [ ] Replace with `execGitCommand` where possible + - [ ] Document exceptions and why they're safe + +- [ ] **Add input validation utilities** + - [ ] Create `isValidRemoteName()` function + - [ ] Create `sanitizeCommitMessage()` function + - [ ] Add validation for all user-controlled inputs + +- [ ] **Security testing** + - [ ] Add unit tests for command injection prevention + - [ ] Add integration tests with malicious inputs + - [ ] Test shell script with malicious `.env` files + +### Long-term (Security Hardening) + +- [ ] **Code review process** + - [ ] Add security checklist for PR reviews + - [ ] Require security review for shell command execution changes + - [ ] Add automated security scanning + +- [ ] **Documentation** + - [ ] Document secure coding practices for shell commands + - [ ] Create security guidelines for contributors + - [ ] Add security section to CONTRIBUTING.md + +--- + +## 🔍 Testing Recommendations + +### Command Injection Tests + +```typescript +// Test cases for merge.ts +describe('merge handler security', () => { + it('should reject branch names with shell metacharacters', () => { + // Test: branchName = "main; rm -rf /" + // Expected: Validation error, command not executed + }); + + it('should sanitize commit messages', () => { + // Test: message = '"; malicious_command; "' + // Expected: Sanitized or rejected + }); +}); + +// Test cases for push.ts +describe('push handler security', () => { + it('should reject remote names with shell metacharacters', () => { + // Test: remote = "origin; malicious_command; #" + // Expected: Validation error, command not executed + }); +}); +``` + +### Shell Script Tests + +```bash +# Test with malicious .env content +echo 'VAR="value; echo PWNED"' > test.env +# Expected: Should not execute the command + +# Test with spaces in values +echo 'VAR="value with spaces"' > test.env +# Expected: Should handle correctly + +# Test with special characters +echo 'VAR="value\$with\$dollars"' > test.env +# Expected: Should handle correctly +``` + +--- + +## 📚 References + +- [OWASP Command Injection](https://owasp.org/www-community/attacks/Command_Injection) +- [Node.js Child Process Security](https://nodejs.org/api/child_process.html#child_process_security_concerns) +- [Shell Script Security Best Practices](https://mywiki.wooledge.org/BashGuide/Practices) + +--- + +## Notes + +- All findings are based on code diff analysis +- No runtime testing was performed +- Assumes attacker has access to API endpoints (authenticated or unauthenticated) +- Fixes should be tested thoroughly before deployment + +--- + +**Last Updated:** $(date) +**Next Review:** After fixes are implemented From 2ab78dd590049cc0592ad3c3e168975d0107cb77 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Tue, 20 Jan 2026 10:59:44 -0500 Subject: [PATCH 21/21] chore: update package-lock.json and enhance kanban-board component imports - Removed unnecessary "dev" flags and replaced them with "devOptional" in package-lock.json for better dependency management. - Added additional imports (useRef, useState, useCallback, useEffect, type RefObject, type ReactNode) to the kanban-board component for improved functionality and state management. --- .../components/views/board-view/kanban-board.tsx | 10 +++++++++- package-lock.json | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 07b58277..8314e74f 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -1,4 +1,12 @@ -import { useMemo } from 'react'; +import { + useMemo, + useRef, + useState, + useCallback, + useEffect, + type RefObject, + type ReactNode, +} from 'react'; import { DragOverlay } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Button } from '@/components/ui/button'; diff --git a/package-lock.json b/package-lock.json index c86ba4aa..64192c40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6218,7 +6218,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6228,7 +6227,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8439,7 +8438,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -11333,6 +11331,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11354,6 +11353,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11375,6 +11375,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11396,6 +11397,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11417,6 +11419,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11438,6 +11441,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11459,6 +11463,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11480,6 +11485,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11501,6 +11507,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11522,6 +11529,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11543,6 +11551,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" },