diff --git a/TODO.md b/TODO.md index 3771806b..4ea7cf34 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,14 @@ - Setting the default model does not seem like it works. +# Performance (completed) + +- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering) +- [x] Render containment on heavy scroll regions (kanban columns, chat history) +- [x] Reduce blur/shadow effects when lists get large +- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect) +- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections) + # UX - Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff diff --git a/apps/ui/package.json b/apps/ui/package.json index cd804908..e66433fd 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -80,7 +80,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/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index fa3d5c94..5beaac94 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -1,115 +1,40 @@ -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 { Spinner } from '@/components/ui/spinner'; 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) => { @@ -144,7 +69,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; @@ -245,10 +169,10 @@ export function ClaudeUsagePopover() { )} @@ -259,26 +183,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 0fee4226..430ccdfa 100644 --- a/apps/ui/src/components/codex-usage-popover.tsx +++ b/apps/ui/src/components/codex-usage-popover.tsx @@ -1,12 +1,11 @@ -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 { Spinner } from '@/components/ui/spinner'; 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 = { @@ -23,9 +22,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); @@ -63,95 +59,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) => { @@ -289,10 +229,10 @@ export function CodexUsagePopover() { )}
diff --git a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx index 84e723fc..9a54f7ab 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, @@ -10,7 +9,7 @@ import { import { Button } from '@/components/ui/button'; import { Folder, FolderOpen, AlertCircle } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; -import { getHttpApiClient } from '@/lib/http-api-client'; +import { useWorkspaceDirectories } from '@/hooks/queries'; interface WorkspaceDirectory { name: string; @@ -24,41 +23,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 ( @@ -80,19 +53,19 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace )} - {error && !isLoading && ( + {errorMessage && !isLoading && (
-

{error}

-
)} - {!isLoading && !error && directories.length === 0 && ( + {!isLoading && !errorMessage && directories.length === 0 && (
@@ -103,7 +76,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
)} - {!isLoading && !error && directories.length > 0 && ( + {!isLoading && !errorMessage && directories.length > 0 && (
{directories.map((dir) => ( @@ -524,7 +436,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/analysis-view.tsx b/apps/ui/src/components/views/analysis-view.tsx index 2143d390..ff1745e3 100644 --- a/apps/ui/src/components/views/analysis-view.tsx +++ b/apps/ui/src/components/views/analysis-view.tsx @@ -1,7 +1,9 @@ import { useCallback, useState } from 'react'; import { createLogger } from '@automaker/utils/logger'; +import { useQueryClient } from '@tanstack/react-query'; import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { @@ -72,6 +74,7 @@ export function AnalysisView() { const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false); const [featureListGenerated, setFeatureListGenerated] = useState(false); const [featureListError, setFeatureListError] = useState(null); + const queryClient = useQueryClient(); // Recursively scan directory const scanDirectory = useCallback( @@ -647,6 +650,11 @@ ${Object.entries(projectAnalysis.filesByExtension) } as any); } + // Invalidate React Query cache to sync UI + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); + setFeatureListGenerated(true); } catch (error) { logger.error('Failed to generate feature list:', error); @@ -656,7 +664,7 @@ ${Object.entries(projectAnalysis.filesByExtension) } finally { setIsGeneratingFeatureList(false); } - }, [currentProject, projectAnalysis]); + }, [currentProject, projectAnalysis, queryClient]); // Toggle folder expansion const toggleFolder = (path: string) => { diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index c72fc8de..2e2222ba 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -35,6 +35,7 @@ import { toast } from 'sonner'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal'; import { Spinner } from '@/components/ui/spinner'; +import { useShallow } from 'zustand/react/shallow'; import { useAutoMode } from '@/hooks/use-auto-mode'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useWindowState } from '@/hooks/use-window-state'; @@ -79,6 +80,10 @@ 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'; +import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation'; // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; @@ -108,9 +113,37 @@ export function BoardView() { isPrimaryWorktreeBranch, getPrimaryWorktreeBranch, setPipelineConfig, - } = useAppStore(); - // Subscribe to pipelineConfigByProject to trigger re-renders when it changes - const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject); + } = useAppStore( + useShallow((state) => ({ + currentProject: state.currentProject, + maxConcurrency: state.maxConcurrency, + setMaxConcurrency: state.setMaxConcurrency, + defaultSkipTests: state.defaultSkipTests, + specCreatingForProject: state.specCreatingForProject, + setSpecCreatingForProject: state.setSpecCreatingForProject, + pendingPlanApproval: state.pendingPlanApproval, + setPendingPlanApproval: state.setPendingPlanApproval, + updateFeature: state.updateFeature, + getCurrentWorktree: state.getCurrentWorktree, + setCurrentWorktree: state.setCurrentWorktree, + getWorktrees: state.getWorktrees, + setWorktrees: state.setWorktrees, + useWorktrees: state.useWorktrees, + enableDependencyBlocking: state.enableDependencyBlocking, + skipVerificationInAutoMode: state.skipVerificationInAutoMode, + planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch, + addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch, + isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch, + getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch, + setPipelineConfig: state.setPipelineConfig, + })) + ); + // Fetch pipeline config via React Query + const { data: pipelineConfig } = usePipelineConfig(currentProject?.path); + const queryClient = useQueryClient(); + + // Subscribe to auto mode events for React Query cache invalidation + useAutoModeQueryInvalidation(currentProject?.path); // 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 @@ -953,9 +986,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 = {}; @@ -1441,6 +1472,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 453c94e3..9cd9d793 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,5 +1,4 @@ -// @ts-nocheck -import { useEffect, useState, useMemo } from 'react'; +import { memo, useEffect, useState, useMemo } from 'react'; import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store'; import type { ReasoningEffort } from '@automaker/types'; import { getProviderFromModel } from '@/lib/utils'; @@ -16,6 +15,7 @@ import { Spinner } from '@/components/ui/spinner'; 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 @@ -50,30 +50,62 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string { interface AgentInfoPanelProps { feature: Feature; + projectPath: string; contextContent?: string; summary?: string; isCurrentAutoTask?: boolean; } -export function AgentInfoPanel({ +export const AgentInfoPanel = memo(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 @@ -125,73 +157,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 @@ -440,4 +405,4 @@ export function AgentInfoPanel({ onOpenChange={setIsSummaryDialogOpen} /> ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index 7dfa4bef..0151a798 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -1,4 +1,5 @@ // @ts-nocheck +import { memo } from 'react'; import { Feature } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { @@ -32,7 +33,7 @@ interface CardActionsProps { onApprovePlan?: () => void; } -export function CardActions({ +export const CardActions = memo(function CardActions({ feature, isCurrentAutoTask, hasContext, @@ -344,4 +345,4 @@ export function CardActions({ )}
); -} +}); 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 ab640c21..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 } = 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 && @@ -215,6 +226,7 @@ export const KanbanCard = memo(function KanbanCard({ {/* Agent Info Panel */} ; + 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/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx index cfb34f18..6db3df66 100644 --- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -15,6 +15,7 @@ import { TaskProgressPanel } from '@/components/ui/task-progress-panel'; import { Markdown } from '@/components/ui/markdown'; import { useAppStore } from '@/store/app-store'; import { extractSummary } from '@/lib/log-parser'; +import { useAgentOutput } from '@/hooks/queries'; import type { AutoModeEvent } from '@/types/electron'; interface AgentOutputModalProps { @@ -45,10 +46,30 @@ export function AgentOutputModal({ branchName, }: AgentOutputModalProps) { const isBacklogPlan = featureId.startsWith('backlog-plan:'); - const [output, setOutput] = useState(''); - 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]); @@ -57,7 +78,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 @@ -67,55 +87,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); - - if (isBacklogPlan) { - setOutput(''); - return; - } - - // 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, isBacklogPlan]); - // Listen to auto mode events and update output useEffect(() => { if (!open) return; @@ -274,8 +245,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); } }); @@ -426,16 +397,16 @@ export function AgentOutputModal({ {!isBacklogPlan && ( )} {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(''); @@ -72,44 +82,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 de9e87ac..3e94c08a 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 @@ -553,28 +558,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( @@ -584,40 +570,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-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts index 3d92139d..6505da2a 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) @@ -168,7 +174,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 @@ -178,7 +183,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/hooks/use-board-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts index 68d746c5..34616875 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,15 +45,12 @@ 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]); + }, [currentProject, loadFeatures]); // Save a new category to the persisted categories file const saveCategory = useCallback( @@ -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; @@ -229,28 +120,13 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { 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 && @@ -272,22 +148,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, }; 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 9ce47c83..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 @@ -1,8 +1,10 @@ import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { Feature } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { createLogger } from '@automaker/utils/logger'; +import { queryKeys } from '@/lib/query-keys'; const logger = createLogger('BoardPersistence'); @@ -12,6 +14,7 @@ interface UseBoardPersistenceProps { export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) { const { updateFeature } = useAppStore(); + const queryClient = useQueryClient(); // Persist feature update to API (replaces saveFeatures) const persistFeatureUpdate = useCallback( @@ -45,7 +48,21 @@ 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), + }); } else if (!result.success) { logger.error('API features.update failed', result); } @@ -53,7 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps logger.error('Failed to persist feature update:', error); } }, - [currentProject, updateFeature] + [currentProject, updateFeature, queryClient] ); // Persist feature creation to API @@ -71,12 +88,16 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps const result = await api.features.create(currentProject.path, feature); if (result.success && result.feature) { updateFeature(result.feature.id, result.feature); + // Invalidate React Query cache to sync UI + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); } } catch (error) { logger.error('Failed to persist feature creation:', error); } }, - [currentProject, updateFeature] + [currentProject, updateFeature, queryClient] ); // Persist feature deletion to API @@ -92,11 +113,15 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps } await api.features.delete(currentProject.path, featureId); + // Invalidate React Query cache to sync UI + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); } catch (error) { logger.error('Failed to persist feature deletion:', error); } }, - [currentProject] + [currentProject, queryClient] ); return { 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/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..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 @@ -1,66 +1,45 @@ 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; + // 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 ?? false, + hasCommits: branchData?.hasCommits ?? false, + }; 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 8e7f6e4e..b089fdf4 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 @@ -3,128 +3,53 @@ import { useNavigate } from '@tanstack/react-router'; 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) { +export function useWorktreeActions() { const navigate = useNavigate(); - const [isPulling, setIsPulling] = useState(false); - const [isPushing, setIsPushing] = useState(false); - const [isSwitching, setIsSwitching] = useState(false); 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 handleOpenInIntegratedTerminal = useCallback( @@ -140,23 +65,15 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre [navigate] ); - 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] + ); const handleOpenInExternalTerminal = useCallback( async (worktree: WorktreeInfo, terminalId?: string) => { @@ -180,9 +97,9 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre ); 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 95589f4b..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,62 +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; - } - // Pass forceRefreshGitHub when this is a manual refresh (not silent polling) - // This clears the GitHub remote cache so users can re-detect after adding a remote - const forceRefreshGitHub = !silent; - const result = await api.worktree.listAll(projectPath, true, forceRefreshGitHub); - 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) @@ -111,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 0214092c..88b39f58 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 @@ -6,6 +6,7 @@ import { 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, @@ -85,10 +86,7 @@ export function WorktreePanel({ handleOpenInIntegratedTerminal, handleOpenInEditor, handleOpenInExternalTerminal, - } = useWorktreeActions({ - fetchWorktrees, - fetchBranches, - }); + } = useWorktreeActions(); const { hasRunningFeatures } = useRunningFeatures({ runningFeatureIds, @@ -156,8 +154,9 @@ export function WorktreePanel({ [currentProject, projectPath, isAutoModeRunningForWorktree] ); - // 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; // View changes dialog state const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false); @@ -171,25 +170,6 @@ export function WorktreePanel({ 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 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/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 0ae6e1e8..986ad65c 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { CircleDot, RefreshCw, SearchX } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; @@ -10,6 +11,7 @@ import { LoadingState } from '@/components/ui/loading-state'; import { ErrorState } from '@/components/ui/error-state'; import { cn, pathsEqual, generateUUID } from '@/lib/utils'; import { toast } from 'sonner'; +import { queryKeys } from '@/lib/query-keys'; import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks'; import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components'; import { ValidationDialog } from './github-issues-view/dialogs'; @@ -36,6 +38,7 @@ export function GitHubIssuesView() { const [filterState, setFilterState] = useState(DEFAULT_ISSUES_FILTER_STATE); const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore(); + const queryClient = useQueryClient(); // Model override for validation const validationModelOverride = useModelOverride({ phase: 'validationModel' }); @@ -153,6 +156,10 @@ export function GitHubIssuesView() { const result = await api.features.create(currentProject.path, feature); if (result.success) { + // Invalidate React Query cache to sync UI + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); toast.success(`Created task: ${issue.title}`); } else { toast.error(result.error || 'Failed to create task'); @@ -163,7 +170,7 @@ export function GitHubIssuesView() { toast.error(err instanceof Error ? err.message : 'Failed to create task'); } }, - [currentProject?.path, currentBranch] + [currentProject?.path, currentBranch, queryClient] ); if (loading) { 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 fbbcb9eb..0a9b3417 100644 --- a/apps/ui/src/components/views/github-prs-view.tsx +++ b/apps/ui/src/components/views/github-prs-view.tsx @@ -1,60 +1,37 @@ -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, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; -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(); @@ -99,7 +76,9 @@ export function GitHubPRsView() {

Failed to Load Pull Requests

-

{error}

+

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

+
+ + )} + + ); + } + 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/ideation-view/components/prompt-list.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx index af52030b..a402b8d1 100644 --- a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx +++ b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx @@ -9,7 +9,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { useIdeationStore } from '@/store/ideation-store'; import { useAppStore } from '@/store/app-store'; -import { getElectronAPI } from '@/lib/electron'; +import { useGenerateIdeationSuggestions } from '@/hooks/mutations'; import { toast } from 'sonner'; import { useNavigate } from '@tanstack/react-router'; import type { IdeaCategory, IdeationPrompt } from '@automaker/types'; @@ -28,6 +28,9 @@ export function PromptList({ category, onBack }: PromptListProps) { const [loadingPromptId, setLoadingPromptId] = useState(null); const [startedPrompts, setStartedPrompts] = useState>(new Set()); const navigate = useNavigate(); + + // React Query mutation + const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? ''); const { getPromptsByCategory, isLoading: isLoadingPrompts, @@ -57,7 +60,7 @@ export function PromptList({ category, onBack }: PromptListProps) { return; } - if (loadingPromptId || generatingPromptIds.has(prompt.id)) return; + if (loadingPromptId || generateMutation.isPending || generatingPromptIds.has(prompt.id)) return; setLoadingPromptId(prompt.id); @@ -69,42 +72,31 @@ export function PromptList({ category, onBack }: PromptListProps) { toast.info(`Generating ideas for "${prompt.title}"...`); setMode('dashboard'); - try { - const api = getElectronAPI(); - const result = await api.ideation?.generateSuggestions( - currentProject.path, - prompt.id, - category - ); - - if (result?.success && result.suggestions) { - updateJobStatus(jobId, 'ready', result.suggestions); - toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, { - duration: 10000, - action: { - label: 'View Ideas', - onClick: () => { - setMode('dashboard'); - navigate({ to: '/ideation' }); + generateMutation.mutate( + { promptId: prompt.id, category }, + { + onSuccess: (data) => { + updateJobStatus(jobId, 'ready', data.suggestions); + toast.success(`Generated ${data.suggestions.length} ideas for "${prompt.title}"`, { + duration: 10000, + action: { + label: 'View Ideas', + onClick: () => { + setMode('dashboard'); + navigate({ to: '/ideation' }); + }, }, - }, - }); - } else { - updateJobStatus( - jobId, - 'error', - undefined, - result?.error || 'Failed to generate suggestions' - ); - toast.error(result?.error || 'Failed to generate suggestions'); + }); + setLoadingPromptId(null); + }, + onError: (error) => { + console.error('Failed to generate suggestions:', error); + updateJobStatus(jobId, 'error', undefined, error.message); + toast.error(error.message); + setLoadingPromptId(null); + }, } - } catch (error) { - console.error('Failed to generate suggestions:', error); - updateJobStatus(jobId, 'error', undefined, (error as Error).message); - toast.error((error as Error).message); - } finally { - setLoadingPromptId(null); - } + ); }; return ( diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx index 883609db..4265650b 100644 --- a/apps/ui/src/components/views/running-agents-view.tsx +++ b/apps/ui/src/components/views/running-agents-view.tsx @@ -1,123 +1,66 @@ -import { useState, useEffect, useCallback } from 'react'; +/** + * 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 { createLogger } from '@automaker/utils/logger'; import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; -import { getElectronAPI, RunningAgent } from '@/lib/electron'; +import { getElectronAPI, 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) { - logger.debug('Fetching running agents list'); - const result = await api.runningAgents.getAll(); - if (result.success && result.runningAgents) { - logger.debug('Running agents list fetched', { - count: result.runningAgents.length, - }); - setRunningAgents(result.runningAgents); - } else { - logger.debug('Running agents list fetch returned empty/failed', { - success: result.success, - }); - } - } else { - logger.debug('Running agents API not available'); - } - } catch (error) { - logger.error('Error fetching running agents:', error); - } finally { - setLoading(false); - setRefreshing(false); - } - }, []); + const logger = createLogger('RunningAgentsView'); - // Initial fetch - useEffect(() => { - fetchRunningAgents(); - }, [fetchRunningAgents]); + // Use React Query for running agents with auto-refresh + const { data, isLoading, isFetching, refetch } = useRunningAgents(); - // Auto-refresh every 2 seconds - useEffect(() => { - const interval = setInterval(() => { - fetchRunningAgents(); - }, 2000); + const runningAgents = data?.agents ?? []; - return () => clearInterval(interval); - }, [fetchRunningAgents]); - - // Subscribe to auto-mode events to update in real-time - useEffect(() => { - const api = getElectronAPI(); - if (!api.autoMode) { - logger.debug('Auto mode API not available for running agents view'); - return; - } - - const unsubscribe = api.autoMode.onEvent((event) => { - logger.debug('Auto mode event in running agents view', { - type: event.type, - }); - // 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(() => { - logger.debug('Manual refresh requested for running agents'); - setRefreshing(true); - fetchRunningAgents(); - }, [fetchRunningAgents]); + refetch(); + }, [refetch]); const handleStopAgent = useCallback( async (agent: RunningAgent) => { - try { - const api = getElectronAPI(); - const isBacklogPlan = agent.featureId.startsWith('backlog-plan:'); - if (isBacklogPlan && api.backlogPlan) { - logger.debug('Stopping backlog plan agent', { featureId: agent.featureId }); + const api = getElectronAPI(); + // Handle backlog plans separately - they use a different API + const isBacklogPlan = agent.featureId.startsWith('backlog-plan:'); + if (isBacklogPlan && api.backlogPlan) { + logger.debug('Stopping backlog plan agent', { featureId: agent.featureId }); + try { await api.backlogPlan.stop(); - fetchRunningAgents(); - return; + } catch (error) { + logger.error('Failed to stop backlog plan', { featureId: agent.featureId, error }); + } finally { + refetch(); } - if (api.autoMode) { - logger.debug('Stopping running agent', { featureId: agent.featureId }); - await api.autoMode.stopFeature(agent.featureId); - // Refresh list after stopping - fetchRunningAgents(); - } else { - logger.debug('Auto mode API not available to stop agent', { featureId: agent.featureId }); - } - } catch (error) { - logger.error('Error stopping agent:', error); + return; } + // Use mutation for regular features + stopFeature.mutate({ featureId: agent.featureId, projectPath: agent.projectPath }); }, - [fetchRunningAgents] + [stopFeature, refetch, logger] ); const handleNavigateToProject = useCallback( (agent: RunningAgent) => { - // Find the project by path const project = projects.find((p) => p.path === agent.projectPath); if (project) { logger.debug('Navigating to running agent project', { @@ -144,7 +87,7 @@ export function RunningAgentsView() { setSelectedAgent(agent); }, []); - if (loading) { + if (isLoading) { return (
@@ -169,8 +112,8 @@ export function RunningAgentsView() {

- - diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx index 2aa1ff3c..d2300c88 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -1,13 +1,11 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useMemo } from 'react'; import { cn } from '@/lib/utils'; -import { getElectronAPI } from '@/lib/electron'; import { useSetupStore } from '@/store/setup-store'; -import { useAppStore } from '@/store/app-store'; +import { useClaudeUsage } from '@/hooks/queries'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { RefreshCw, AlertCircle } from 'lucide-react'; -const ERROR_NO_API = 'Claude usage API not available'; const CLAUDE_USAGE_TITLE = 'Claude Usage'; const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.'; const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.'; @@ -15,13 +13,10 @@ const CLAUDE_LOGIN_COMMAND = 'claude login'; const CLAUDE_NO_USAGE_MESSAGE = 'Usage limits are not available yet. Try refreshing if this persists.'; const UPDATED_LABEL = 'Updated'; -const CLAUDE_FETCH_ERROR = 'Failed to fetch usage'; const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage'; 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; // Using purple/indigo for Claude branding const USAGE_COLOR_CRITICAL = 'bg-red-500'; const USAGE_COLOR_WARNING = 'bg-amber-500'; @@ -81,77 +76,31 @@ function UsageCard({ export function ClaudeUsageSection() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); - const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); const canFetchUsage = !!claudeAuthStatus?.authenticated; + + // Use React Query for data fetching with automatic polling + const { + data: claudeUsage, + isLoading, + isFetching, + error, + dataUpdatedAt, + refetch, + } = useClaudeUsage(canFetchUsage); + // If we have usage data, we can show it even if auth status is unsure const hasUsage = !!claudeUsage; - const lastUpdatedLabel = claudeUsageLastUpdated - ? new Date(claudeUsageLastUpdated).toLocaleString() - : null; + const lastUpdatedLabel = useMemo(() => { + return dataUpdatedAt ? new Date(dataUpdatedAt).toLocaleString() : null; + }, [dataUpdatedAt]); + + const errorMessage = error instanceof Error ? error.message : error ? String(error) : null; const showAuthWarning = (!canFetchUsage && !hasUsage && !isLoading) || - (error && error.includes('Authentication required')); - - const isStale = - !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS; - - const fetchUsage = useCallback(async () => { - setIsLoading(true); - setError(null); - try { - const api = getElectronAPI(); - if (!api.claude) { - setError(ERROR_NO_API); - return; - } - const result = await api.claude.getUsage(); - - if ('error' in result) { - // Check for auth errors specifically - if ( - result.message?.includes('Authentication required') || - result.error?.includes('Authentication required') - ) { - // We'll show the auth warning UI instead of a generic error - } else { - setError(result.message || result.error); - } - return; - } - - setClaudeUsage(result); - } catch (fetchError) { - const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR; - setError(message); - } finally { - setIsLoading(false); - } - }, [setClaudeUsage]); - - useEffect(() => { - // Initial fetch if authenticated and stale - // Compute staleness inside effect to avoid re-running when Date.now() changes - const isDataStale = - !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS; - if (canFetchUsage && isDataStale) { - void fetchUsage(); - } - }, [fetchUsage, canFetchUsage, claudeUsageLastUpdated]); - - useEffect(() => { - if (!canFetchUsage) return undefined; - - const intervalId = setInterval(() => { - void fetchUsage(); - }, REFRESH_INTERVAL_MS); - - return () => clearInterval(intervalId); - }, [fetchUsage, canFetchUsage]); + (errorMessage && errorMessage.includes('Authentication required')); return (
refetch()} + disabled={isFetching} className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50" data-testid="refresh-claude-usage" title={CLAUDE_REFRESH_LABEL} > - {isLoading ? : } + {isFetching ? : }

{CLAUDE_USAGE_SUBTITLE}

@@ -195,10 +144,10 @@ export function ClaudeUsageSection() {
)} - {error && !showAuthWarning && ( + {errorMessage && !showAuthWarning && (
-
{error}
+
{errorMessage}
)} @@ -220,7 +169,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/cli-status/claude-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx index a6474a7a..9836f76e 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 { Spinner } from '@/components/ui/spinner'; import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -35,10 +36,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 (
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; @@ -55,46 +50,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) { @@ -163,13 +119,13 @@ export function CodexUsageSection() {

{CODEX_USAGE_SUBTITLE}

@@ -183,10 +139,10 @@ export function CodexUsageSection() {
)} - {error && ( + {errorMessage && (
-
{error}
+
{errorMessage}
)} {hasMetrics && ( @@ -211,7 +167,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 0ec718a3..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 @@ -1,239 +1,79 @@ -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 { ProviderToggle } from './provider-toggle'; -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, + error: cliStatusData.auth.error, }; - 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) => { @@ -241,7 +81,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); @@ -255,7 +95,7 @@ export function OpencodeSettingsTab() { setIsSaving(true); try { toggleOpencodeModel(model, enabled); - } catch (error) { + } catch { toast.error('Failed to update models'); } finally { setIsSaving(false); @@ -269,7 +109,7 @@ export function OpencodeSettingsTab() { setIsSaving(true); try { toggleDynamicModel(modelId, enabled); - } catch (error) { + } catch { toast.error('Failed to update dynamic model'); } finally { setIsSaving(false); @@ -287,7 +127,7 @@ export function OpencodeSettingsTab() { ); } - const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed'; + const isLoadingDynamicModels = isFetchingProviders || isFetchingModels; return (
@@ -297,7 +137,7 @@ export function OpencodeSettingsTab() { @@ -310,8 +150,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/spec-view/hooks/use-spec-generation.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts index 30a8150f..6cf7bf50 100644 --- a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts +++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts @@ -10,6 +10,7 @@ import { createElement } from 'react'; import { SPEC_FILE_WRITE_DELAY, STATUS_CHECK_INTERVAL_MS } from '../constants'; import type { FeatureCount } from '../types'; import type { SpecRegenerationEvent } from '@/types/electron'; +import { useCreateSpec, useRegenerateSpec, useGenerateFeatures } from '@/hooks/mutations'; interface UseSpecGenerationOptions { loadSpec: () => Promise; @@ -18,6 +19,11 @@ interface UseSpecGenerationOptions { export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { const { currentProject } = useAppStore(); + // React Query mutations + const createSpecMutation = useCreateSpec(currentProject?.path ?? ''); + const regenerateSpecMutation = useRegenerateSpec(currentProject?.path ?? ''); + const generateFeaturesMutation = useGenerateFeatures(currentProject?.path ?? ''); + // Dialog visibility state const [showCreateDialog, setShowCreateDialog] = useState(false); const [showRegenerateDialog, setShowRegenerateDialog] = useState(false); @@ -427,47 +433,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { logsRef.current = ''; setLogs(''); logger.debug('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures); - try { - const api = getElectronAPI(); - if (!api.specRegeneration) { - logger.error('[useSpecGeneration] Spec regeneration not available'); - setIsCreating(false); - return; - } - const result = await api.specRegeneration.create( - currentProject.path, - projectOverview.trim(), - generateFeatures, - analyzeProjectOnCreate, - generateFeatures ? featureCountOnCreate : undefined - ); - if (!result.success) { - const errorMsg = result.error || 'Unknown error'; - logger.error('[useSpecGeneration] Failed to start spec creation:', errorMsg); - setIsCreating(false); - setCurrentPhase('error'); - setErrorMessage(errorMsg); - const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`; - logsRef.current = errorLog; - setLogs(errorLog); + createSpecMutation.mutate( + { + projectOverview: projectOverview.trim(), + generateFeatures, + analyzeProject: analyzeProjectOnCreate, + featureCount: generateFeatures ? featureCountOnCreate : undefined, + }, + { + onError: (error) => { + const errorMsg = error.message; + logger.error('[useSpecGeneration] Failed to create spec:', errorMsg); + setIsCreating(false); + setCurrentPhase('error'); + setErrorMessage(errorMsg); + const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`; + logsRef.current = errorLog; + setLogs(errorLog); + }, } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error('[useSpecGeneration] Failed to create spec:', errorMsg); - setIsCreating(false); - setCurrentPhase('error'); - setErrorMessage(errorMsg); - const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`; - logsRef.current = errorLog; - setLogs(errorLog); - } + ); }, [ currentProject, projectOverview, generateFeatures, analyzeProjectOnCreate, featureCountOnCreate, + createSpecMutation, ]); const handleRegenerate = useCallback(async () => { @@ -483,47 +476,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { '[useSpecGeneration] Starting spec regeneration, generateFeatures:', generateFeaturesOnRegenerate ); - try { - const api = getElectronAPI(); - if (!api.specRegeneration) { - logger.error('[useSpecGeneration] Spec regeneration not available'); - setIsRegenerating(false); - return; - } - const result = await api.specRegeneration.generate( - currentProject.path, - projectDefinition.trim(), - generateFeaturesOnRegenerate, - analyzeProjectOnRegenerate, - generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined - ); - if (!result.success) { - const errorMsg = result.error || 'Unknown error'; - logger.error('[useSpecGeneration] Failed to start regeneration:', errorMsg); - setIsRegenerating(false); - setCurrentPhase('error'); - setErrorMessage(errorMsg); - const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`; - logsRef.current = errorLog; - setLogs(errorLog); + regenerateSpecMutation.mutate( + { + projectDefinition: projectDefinition.trim(), + generateFeatures: generateFeaturesOnRegenerate, + analyzeProject: analyzeProjectOnRegenerate, + featureCount: generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined, + }, + { + onError: (error) => { + const errorMsg = error.message; + logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg); + setIsRegenerating(false); + setCurrentPhase('error'); + setErrorMessage(errorMsg); + const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`; + logsRef.current = errorLog; + setLogs(errorLog); + }, } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg); - setIsRegenerating(false); - setCurrentPhase('error'); - setErrorMessage(errorMsg); - const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`; - logsRef.current = errorLog; - setLogs(errorLog); - } + ); }, [ currentProject, projectDefinition, generateFeaturesOnRegenerate, analyzeProjectOnRegenerate, featureCountOnRegenerate, + regenerateSpecMutation, ]); const handleGenerateFeatures = useCallback(async () => { @@ -536,36 +516,20 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { logsRef.current = ''; setLogs(''); logger.debug('[useSpecGeneration] Starting feature generation from existing spec'); - try { - const api = getElectronAPI(); - if (!api.specRegeneration) { - logger.error('[useSpecGeneration] Spec regeneration not available'); - setIsGeneratingFeatures(false); - return; - } - const result = await api.specRegeneration.generateFeatures(currentProject.path); - if (!result.success) { - const errorMsg = result.error || 'Unknown error'; - logger.error('[useSpecGeneration] Failed to start feature generation:', errorMsg); + generateFeaturesMutation.mutate(undefined, { + onError: (error) => { + const errorMsg = error.message; + logger.error('[useSpecGeneration] Failed to generate features:', errorMsg); setIsGeneratingFeatures(false); setCurrentPhase('error'); setErrorMessage(errorMsg); - const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`; + const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`; logsRef.current = errorLog; setLogs(errorLog); - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error('[useSpecGeneration] Failed to generate features:', errorMsg); - setIsGeneratingFeatures(false); - setCurrentPhase('error'); - setErrorMessage(errorMsg); - const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`; - logsRef.current = errorLog; - setLogs(errorLog); - } - }, [currentProject]); + }, + }); + }, [currentProject, generateFeaturesMutation]); const handleSync = useCallback(async () => { if (!currentProject) return; diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts index 9fc09b81..5aff3df4 100644 --- a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts +++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts @@ -1,62 +1,51 @@ import { useEffect, useState, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; import { useAppStore } from '@/store/app-store'; - -const logger = createLogger('SpecLoading'); -import { getElectronAPI } from '@/lib/electron'; +import { useSpecFile, useSpecRegenerationStatus } from '@/hooks/queries'; +import { useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/query-keys'; export function useSpecLoading() { const { currentProject, setAppSpec } = useAppStore(); - const [isLoading, setIsLoading] = useState(true); + const queryClient = useQueryClient(); const [specExists, setSpecExists] = useState(true); - const [isGenerationRunning, setIsGenerationRunning] = useState(false); - const loadSpec = useCallback(async () => { - if (!currentProject) return; + // React Query hooks + const specFileQuery = useSpecFile(currentProject?.path); + const statusQuery = useSpecRegenerationStatus(currentProject?.path); - setIsLoading(true); - try { - const api = getElectronAPI(); - - // Check if spec generation is running - if (api.specRegeneration) { - const status = await api.specRegeneration.status(currentProject.path); - if (status.success && status.isRunning) { - logger.debug('Spec generation is running for this project'); - setIsGenerationRunning(true); - } else { - setIsGenerationRunning(false); - } - } else { - setIsGenerationRunning(false); - } - - // Always try to load the spec file, even if generation is running - // This allows users to view their existing spec while generating features - const result = await api.readFile(`${currentProject.path}/.automaker/app_spec.txt`); - - if (result.success && result.content) { - setAppSpec(result.content); - setSpecExists(true); - } else { - // File doesn't exist - setAppSpec(''); - setSpecExists(false); - } - } catch (error) { - logger.error('Failed to load spec:', error); - setSpecExists(false); - } finally { - setIsLoading(false); - } - }, [currentProject, setAppSpec]); + const isGenerationRunning = statusQuery.data?.isRunning ?? false; + // Update app store and specExists when spec file data changes useEffect(() => { - loadSpec(); - }, [loadSpec]); + if (specFileQuery.data && !isGenerationRunning) { + setAppSpec(specFileQuery.data.content); + setSpecExists(specFileQuery.data.exists); + } + }, [specFileQuery.data, setAppSpec, isGenerationRunning]); + + // Manual reload function (invalidates cache) + const loadSpec = useCallback(async () => { + if (!currentProject?.path) return; + + // Fetch fresh status data to avoid stale cache issues + // Using fetchQuery ensures we get the latest data before checking + const statusData = await queryClient.fetchQuery<{ isRunning: boolean }>({ + queryKey: queryKeys.specRegeneration.status(currentProject.path), + staleTime: 0, // Force fresh fetch + }); + + if (statusData?.isRunning) { + return; + } + + // Invalidate and refetch spec file + await queryClient.invalidateQueries({ + queryKey: queryKeys.spec.file(currentProject.path), + }); + }, [currentProject?.path, queryClient]); return { - isLoading, + isLoading: specFileQuery.isLoading, specExists, setSpecExists, isGenerationRunning, diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts index 5b0bbb47..03812fd3 100644 --- a/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts +++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts @@ -1,28 +1,20 @@ import { useState } from 'react'; -import { createLogger } from '@automaker/utils/logger'; import { useAppStore } from '@/store/app-store'; - -const logger = createLogger('SpecSave'); -import { getElectronAPI } from '@/lib/electron'; +import { useSaveSpec } from '@/hooks/mutations'; export function useSpecSave() { const { currentProject, appSpec, setAppSpec } = useAppStore(); - const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); + // React Query mutation + const saveMutation = useSaveSpec(currentProject?.path ?? ''); + const saveSpec = async () => { if (!currentProject) return; - setIsSaving(true); - try { - const api = getElectronAPI(); - await api.writeFile(`${currentProject.path}/.automaker/app_spec.txt`, appSpec); - setHasChanges(false); - } catch (error) { - logger.error('Failed to save spec:', error); - } finally { - setIsSaving(false); - } + saveMutation.mutate(appSpec, { + onSuccess: () => setHasChanges(false), + }); }; const handleChange = (value: string) => { @@ -31,7 +23,7 @@ export function useSpecSave() { }; return { - isSaving, + isSaving: saveMutation.isPending, hasChanges, setHasChanges, saveSpec, 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..0eb07a1d --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts @@ -0,0 +1,388 @@ +/** + * 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 + * + * @example + * ```tsx + * const stopFeature = useStopFeature(); + * // Simple stop + * stopFeature.mutate('feature-id'); + * // Stop with project path for cache invalidation + * stopFeature.mutate({ featureId: 'feature-id', projectPath: '/path/to/project' }); + * ``` + */ +export function useStopFeature() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: string | { featureId: string; projectPath?: string }) => { + const featureId = typeof input === 'string' ? input : input.featureId; + const api = getElectronAPI(); + const result = await api.autoMode.stopFeature(featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to stop feature'); + } + // Return projectPath for use in onSuccess + return { ...result, projectPath: typeof input === 'string' ? undefined : input.projectPath }; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() }); + // Also invalidate features cache if projectPath is provided + if (data.projectPath) { + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(data.projectPath) }); + } + 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..29395cb3 --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-github-mutations.ts @@ -0,0 +1,163 @@ +/** + * 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'; +import { resolveModelString } from '@automaker/model-resolver'; + +/** + * 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, + }; + + // Resolve model alias to canonical model identifier + const resolvedModel = model ? resolveModelString(model) : undefined; + + const result = await api.github.validateIssue( + projectPath, + validationInput, + resolvedModel, + 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..a9e890c0 --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-spec-mutations.ts @@ -0,0 +1,184 @@ +/** + * 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) => { + // Guard against empty projectPath to prevent writing to invalid locations + if (!projectPath || projectPath.trim() === '') { + throw new Error('Invalid project path: cannot save spec without a valid project'); + } + + 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, + }); + }, + }); +} 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..78db6101 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-features.ts @@ -0,0 +1,136 @@ +/** + * 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'; + +const FEATURES_REFETCH_ON_FOCUS = false; +const FEATURES_REFETCH_ON_RECONNECT = false; + +/** + * 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, + refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, + refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, + }); +} + +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, + refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, + refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, + }); +} + +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; + }, + refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, + refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, + }); +} 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..75002226 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-running-agents.ts @@ -0,0 +1,66 @@ +/** + * 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'; + +const RUNNING_AGENTS_REFETCH_ON_FOCUS = false; +const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false; + +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 + refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS, + refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT, + }); +} + +/** + * 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..cb77ff35 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-settings.ts @@ -0,0 +1,123 @@ +/** + * 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({ + // Include sources in query key so different source combinations have separate caches + queryKey: queryKeys.settings.agents(projectPath ?? '', sources), + 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..21f0267d --- /dev/null +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -0,0 +1,83 @@ +/** + * 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; +const USAGE_REFETCH_ON_FOCUS = false; +const USAGE_REFETCH_ON_RECONNECT = false; + +/** + * 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, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} + +/** + * 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, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} 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..551894ef --- /dev/null +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -0,0 +1,270 @@ +/** + * 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'; + +const WORKTREE_REFETCH_ON_FOCUS = false; +const WORKTREE_REFETCH_ON_RECONNECT = false; + +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, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * 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, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * 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, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * 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, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +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({ + // Include includeRemote in query key so different configurations have separate caches + queryKey: queryKeys.worktrees.branches(worktreePath ?? '', includeRemote), + 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, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * 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, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * 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, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/use-board-background-settings.ts b/apps/ui/src/hooks/use-board-background-settings.ts index fdb09b36..33618941 100644 --- a/apps/ui/src/hooks/use-board-background-settings.ts +++ b/apps/ui/src/hooks/use-board-background-settings.ts @@ -1,36 +1,26 @@ import { useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; import { useAppStore } from '@/store/app-store'; -import { getHttpApiClient } from '@/lib/http-api-client'; -import { toast } from 'sonner'; - -const logger = createLogger('BoardBackground'); +import { useUpdateProjectSettings } from '@/hooks/mutations'; /** - * Hook for managing board background settings with automatic persistence to server + * Hook for managing board background settings with automatic persistence to server. + * Uses React Query mutation for server persistence with automatic error handling. */ export function useBoardBackgroundSettings() { const store = useAppStore(); - const httpClient = getHttpApiClient(); + + // Get the mutation without a fixed project path - we'll pass it with each call + const updateProjectSettings = useUpdateProjectSettings(); // Helper to persist settings to server const persistSettings = useCallback( - async (projectPath: string, settingsToUpdate: Record) => { - try { - const result = await httpClient.settings.updateProject(projectPath, { - boardBackground: settingsToUpdate, - }); - - if (!result.success) { - logger.error('Failed to persist settings:', result.error); - toast.error('Failed to save settings'); - } - } catch (error) { - logger.error('Failed to persist settings:', error); - toast.error('Failed to save settings'); - } + (projectPath: string, settingsToUpdate: Record) => { + updateProjectSettings.mutate({ + projectPath, + settings: { boardBackground: settingsToUpdate }, + }); }, - [httpClient] + [updateProjectSettings] ); // Get current background settings for a project diff --git a/apps/ui/src/hooks/use-guided-prompts.ts b/apps/ui/src/hooks/use-guided-prompts.ts index e192d6b3..e7d18e84 100644 --- a/apps/ui/src/hooks/use-guided-prompts.ts +++ b/apps/ui/src/hooks/use-guided-prompts.ts @@ -2,12 +2,12 @@ * Hook for fetching guided prompts from the backend API * * This hook provides the single source of truth for guided prompts, - * fetched from the backend /api/ideation/prompts endpoint. + * with caching via React Query. */ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import type { IdeationPrompt, PromptCategory, IdeaCategory } from '@automaker/types'; -import { getElectronAPI } from '@/lib/electron'; +import { useIdeationPrompts } from '@/hooks/queries'; interface UseGuidedPromptsReturn { prompts: IdeationPrompt[]; @@ -21,36 +21,10 @@ interface UseGuidedPromptsReturn { } export function useGuidedPrompts(): UseGuidedPromptsReturn { - const [prompts, setPrompts] = useState([]); - const [categories, setCategories] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const { data, isLoading, error, refetch } = useIdeationPrompts(); - const fetchPrompts = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - const api = getElectronAPI(); - const result = await api.ideation?.getPrompts(); - - if (result?.success) { - setPrompts(result.prompts || []); - setCategories(result.categories || []); - } else { - setError(result?.error || 'Failed to fetch prompts'); - } - } catch (err) { - console.error('Failed to fetch guided prompts:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch prompts'); - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - fetchPrompts(); - }, [fetchPrompts]); + const prompts = data?.prompts ?? []; + const categories = data?.categories ?? []; const getPromptsByCategory = useCallback( (category: IdeaCategory): IdeationPrompt[] => { @@ -73,12 +47,23 @@ export function useGuidedPrompts(): UseGuidedPromptsReturn { [categories] ); + // Convert async refetch to match the expected interface + const handleRefetch = useCallback(async () => { + await refetch(); + }, [refetch]); + + // Convert error to string for backward compatibility + const errorMessage = useMemo(() => { + if (!error) return null; + return error instanceof Error ? error.message : String(error); + }, [error]); + return { prompts, categories, isLoading, - error, - refetch: fetchPrompts, + error: errorMessage, + refetch: handleRefetch, getPromptsByCategory, getPromptById, getCategoryById, diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index 53c906e1..a4531d22 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -1,11 +1,13 @@ import { useEffect, useRef } from 'react'; import { useAppStore } from '@/store/app-store'; -import { getHttpApiClient } from '@/lib/http-api-client'; +import { useProjectSettings } from '@/hooks/queries'; /** * Hook that loads project settings from the server when the current project changes. * This ensures that settings like board backgrounds are properly restored when * switching between projects or restarting the app. + * + * Uses React Query for data fetching with automatic caching. */ export function useProjectSettingsLoader() { const currentProject = useAppStore((state) => state.currentProject); @@ -25,118 +27,104 @@ export function useProjectSettingsLoader() { ); const setCurrentProject = useAppStore((state) => state.setCurrentProject); - const loadingRef = useRef(null); - const currentProjectRef = useRef(null); + const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null); + // Fetch project settings with React Query + const { data: settings, dataUpdatedAt } = useProjectSettings(currentProject?.path); + + // Apply settings when data changes useEffect(() => { - currentProjectRef.current = currentProject?.path ?? null; - - if (!currentProject?.path) { + if (!currentProject?.path || !settings) { return; } - // Prevent loading the same project multiple times - if (loadingRef.current === currentProject.path) { + // Prevent applying the same settings multiple times + if ( + appliedProjectRef.current?.path === currentProject.path && + appliedProjectRef.current?.dataUpdatedAt === dataUpdatedAt + ) { return; } - loadingRef.current = currentProject.path; - const requestedProjectPath = currentProject.path; + appliedProjectRef.current = { path: currentProject.path, dataUpdatedAt }; + const projectPath = currentProject.path; - const loadProjectSettings = async () => { - try { - const httpClient = getHttpApiClient(); - const result = await httpClient.settings.getProject(requestedProjectPath); + const bg = settings.boardBackground; - // Race condition protection: ignore stale results if project changed - if (currentProjectRef.current !== requestedProjectPath) { - return; - } + // Apply boardBackground if present + if (bg?.imagePath) { + setBoardBackground(projectPath, bg.imagePath); + } - if (result.success && result.settings) { - const bg = result.settings.boardBackground; + // Settings map for cleaner iteration + const settingsMap = { + cardOpacity: setCardOpacity, + columnOpacity: setColumnOpacity, + columnBorderEnabled: setColumnBorderEnabled, + cardGlassmorphism: setCardGlassmorphism, + cardBorderEnabled: setCardBorderEnabled, + cardBorderOpacity: setCardBorderOpacity, + hideScrollbar: setHideScrollbar, + } as const; - // Apply boardBackground if present - if (bg?.imagePath) { - setBoardBackground(requestedProjectPath, bg.imagePath); - } - - // Settings map for cleaner iteration - const settingsMap = { - cardOpacity: setCardOpacity, - columnOpacity: setColumnOpacity, - columnBorderEnabled: setColumnBorderEnabled, - cardGlassmorphism: setCardGlassmorphism, - cardBorderEnabled: setCardBorderEnabled, - cardBorderOpacity: setCardBorderOpacity, - hideScrollbar: setHideScrollbar, - } as const; - - // Apply all settings that are defined - for (const [key, setter] of Object.entries(settingsMap)) { - const value = bg?.[key as keyof typeof bg]; - if (value !== undefined) { - (setter as (path: string, val: typeof value) => void)(requestedProjectPath, value); - } - } - - // Apply worktreePanelVisible if present - if (result.settings.worktreePanelVisible !== undefined) { - setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible); - } - - // Apply showInitScriptIndicator if present - if (result.settings.showInitScriptIndicator !== undefined) { - setShowInitScriptIndicator( - requestedProjectPath, - result.settings.showInitScriptIndicator - ); - } - - // Apply defaultDeleteBranch if present - if (result.settings.defaultDeleteBranchWithWorktree !== undefined) { - setDefaultDeleteBranch( - requestedProjectPath, - result.settings.defaultDeleteBranchWithWorktree - ); - } - - // Apply autoDismissInitScriptIndicator if present - if (result.settings.autoDismissInitScriptIndicator !== undefined) { - setAutoDismissInitScriptIndicator( - requestedProjectPath, - result.settings.autoDismissInitScriptIndicator - ); - } - - // Apply activeClaudeApiProfileId if present - // This is stored directly on the project, so we need to update the currentProject - // Type assertion needed because API returns Record - const settingsWithProfile = result.settings as Record; - const activeClaudeApiProfileId = settingsWithProfile.activeClaudeApiProfileId as - | string - | null - | undefined; - if (activeClaudeApiProfileId !== undefined) { - const updatedProject = useAppStore.getState().currentProject; - if ( - updatedProject && - updatedProject.path === requestedProjectPath && - updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId - ) { - setCurrentProject({ - ...updatedProject, - activeClaudeApiProfileId, - }); - } - } - } - } catch (error) { - console.error('Failed to load project settings:', error); - // Don't show error toast - just log it + // Apply all settings that are defined + for (const [key, setter] of Object.entries(settingsMap)) { + const value = bg?.[key as keyof typeof bg]; + if (value !== undefined) { + (setter as (path: string, val: typeof value) => void)(projectPath, value); } - }; + } - loadProjectSettings(); - }, [currentProject?.path]); + // Apply worktreePanelVisible if present + if (settings.worktreePanelVisible !== undefined) { + setWorktreePanelVisible(projectPath, settings.worktreePanelVisible); + } + + // Apply showInitScriptIndicator if present + if (settings.showInitScriptIndicator !== undefined) { + setShowInitScriptIndicator(projectPath, settings.showInitScriptIndicator); + } + + // Apply defaultDeleteBranchWithWorktree if present + if (settings.defaultDeleteBranchWithWorktree !== undefined) { + setDefaultDeleteBranch(projectPath, settings.defaultDeleteBranchWithWorktree); + } + + // Apply autoDismissInitScriptIndicator if present + if (settings.autoDismissInitScriptIndicator !== undefined) { + setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator); + } + + // Apply activeClaudeApiProfileId if present + if (settings.activeClaudeApiProfileId !== undefined) { + const updatedProject = useAppStore.getState().currentProject; + if ( + updatedProject && + updatedProject.path === projectPath && + updatedProject.activeClaudeApiProfileId !== settings.activeClaudeApiProfileId + ) { + setCurrentProject({ + ...updatedProject, + activeClaudeApiProfileId: settings.activeClaudeApiProfileId, + }); + } + } + }, [ + currentProject?.path, + settings, + dataUpdatedAt, + setBoardBackground, + setCardOpacity, + setColumnOpacity, + setColumnBorderEnabled, + setCardGlassmorphism, + setCardBorderEnabled, + setCardBorderOpacity, + setHideScrollbar, + setWorktreePanelVisible, + setShowInitScriptIndicator, + setDefaultDeleteBranch, + setAutoDismissInitScriptIndicator, + setCurrentProject, + ]); } 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..eb0bfb4d --- /dev/null +++ b/apps/ui/src/hooks/use-query-invalidation.ts @@ -0,0 +1,234 @@ +/** + * 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(); + + // Check if GitHub API is available before subscribing + if (!api.github?.onValidationEvent) { + return; + } + + 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); +} diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index d1daa4bd..05b8d183 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -730,8 +730,6 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false, lastProjectDir: settings.lastProjectDir ?? '', recentFolders: settings.recentFolders ?? [], - // Event hooks - eventHooks: settings.eventHooks ?? [], // Terminal font (nested in terminalState) ...(settings.terminalFontFamily && { terminalState: { @@ -810,7 +808,6 @@ function buildSettingsUpdateFromStore(): Record { lastProjectDir: state.lastProjectDir, recentFolders: state.recentFolders, terminalFontFamily: state.terminalState.fontFamily, - eventHooks: state.eventHooks, }; } 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..feb69c65 --- /dev/null +++ b/apps/ui/src/lib/query-keys.ts @@ -0,0 +1,282 @@ +/** + * 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, includeRemote = false) => + ['worktrees', 'branches', worktreePath, { includeRemote }] 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, sources?: Array<'user' | 'project'>) => + ['settings', 'agents', projectPath, sources ?? []] 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 e1a115d5..1660e048 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 { ThemeOption, themeOptions } from '@/config/theme-options'; import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; @@ -37,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; @@ -892,9 +896,14 @@ function RootLayoutContent() { function RootLayout() { return ( - - - + + + + + {SHOW_QUERY_DEVTOOLS ? ( + + ) : null} + ); } diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index a8a6e53a..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); @@ -1120,3 +1121,9 @@ animation: none; } } + +.perf-contain { + contain: layout paint; + content-visibility: auto; + contain-intrinsic-size: auto var(--perf-contain-intrinsic-size); +} 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')]; diff --git a/package-lock.json b/package-lock.json index c851c9aa..c86ba4aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,7 +128,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", @@ -5594,9 +5595,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.19", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz", + "integrity": "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==", + "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", @@ -5604,12 +5615,12 @@ } }, "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.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz", + "integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.12" + "@tanstack/query-core": "5.90.19" }, "funding": { "type": "github", @@ -5619,6 +5630,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",