diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts new file mode 100644 index 00000000..18e38120 --- /dev/null +++ b/apps/ui/src/hooks/queries/index.ts @@ -0,0 +1,91 @@ +/** + * Query Hooks Barrel Export + * + * Central export point for all React Query hooks. + * Import from this file for cleaner imports across the app. + * + * @example + * ```tsx + * import { useFeatures, useGitHubIssues, useClaudeUsage } from '@/hooks/queries'; + * ``` + */ + +// Features +export { useFeatures, useFeature, useAgentOutput } from './use-features'; + +// GitHub +export { + useGitHubIssues, + useGitHubPRs, + useGitHubValidations, + useGitHubRemote, + useGitHubIssueComments, +} from './use-github'; + +// Usage +export { useClaudeUsage, useCodexUsage } from './use-usage'; + +// Running Agents +export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; + +// Worktrees +export { + useWorktrees, + useWorktreeInfo, + useWorktreeStatus, + useWorktreeDiffs, + useWorktreeBranches, + useWorktreeInitScript, + useAvailableEditors, +} from './use-worktrees'; + +// Settings +export { + useGlobalSettings, + useProjectSettings, + useSettingsStatus, + useCredentials, + useDiscoveredAgents, +} from './use-settings'; + +// Models +export { + useAvailableModels, + useCodexModels, + useOpencodeModels, + useOpencodeProviders, + useModelProviders, +} from './use-models'; + +// CLI Status +export { + useClaudeCliStatus, + useCursorCliStatus, + useCodexCliStatus, + useOpencodeCliStatus, + useGitHubCliStatus, + useApiKeysStatus, + usePlatformInfo, +} from './use-cli-status'; + +// Ideation +export { useIdeationPrompts, useIdeas, useIdea } from './use-ideation'; + +// Sessions +export { useSessions, useSessionHistory, useSessionQueue } from './use-sessions'; + +// Git +export { useGitDiffs } from './use-git'; + +// Pipeline +export { usePipelineConfig } from './use-pipeline'; + +// Spec +export { useSpecFile, useSpecRegenerationStatus } from './use-spec'; + +// Cursor Permissions +export { useCursorPermissionsQuery } from './use-cursor-permissions'; +export type { CursorPermissionsData } from './use-cursor-permissions'; + +// Workspace +export { useWorkspaceDirectories } from './use-workspace'; diff --git a/apps/ui/src/hooks/queries/use-cli-status.ts b/apps/ui/src/hooks/queries/use-cli-status.ts new file mode 100644 index 00000000..71ea2ae9 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-cli-status.ts @@ -0,0 +1,147 @@ +/** + * CLI Status Query Hooks + * + * React Query hooks for fetching CLI tool status (Claude, Cursor, Codex, etc.) + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +/** + * Fetch Claude CLI status + * + * @returns Query result with Claude CLI status + */ +export function useClaudeCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.claude(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getClaudeStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Claude status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch Cursor CLI status + * + * @returns Query result with Cursor CLI status + */ +export function useCursorCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.cursor(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getCursorStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Cursor status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch Codex CLI status + * + * @returns Query result with Codex CLI status + */ +export function useCodexCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.codex(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getCodexStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Codex status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch OpenCode CLI status + * + * @returns Query result with OpenCode CLI status + */ +export function useOpencodeCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.opencode(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getOpencodeStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch OpenCode status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch GitHub CLI status + * + * @returns Query result with GitHub CLI status + */ +export function useGitHubCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.github(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getGhStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch GitHub CLI status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch API keys status + * + * @returns Query result with API keys status + */ +export function useApiKeysStatus() { + return useQuery({ + queryKey: queryKeys.cli.apiKeys(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getApiKeys(); + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + +/** + * Fetch platform info + * + * @returns Query result with platform info + */ +export function usePlatformInfo() { + return useQuery({ + queryKey: queryKeys.cli.platform(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getPlatform(); + if (!result.success) { + throw new Error('Failed to fetch platform info'); + } + return result; + }, + staleTime: Infinity, // Platform info never changes + }); +} diff --git a/apps/ui/src/hooks/queries/use-cursor-permissions.ts b/apps/ui/src/hooks/queries/use-cursor-permissions.ts new file mode 100644 index 00000000..5d2e24f0 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-cursor-permissions.ts @@ -0,0 +1,58 @@ +/** + * Cursor Permissions Query Hooks + * + * React Query hooks for fetching Cursor CLI permissions. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { CursorPermissionProfile } from '@automaker/types'; + +export interface CursorPermissionsData { + activeProfile: CursorPermissionProfile | null; + effectivePermissions: { allow: string[]; deny: string[] } | null; + hasProjectConfig: boolean; + availableProfiles: Array<{ + id: string; + name: string; + description: string; + permissions: { allow: string[]; deny: string[] }; + }>; +} + +/** + * Fetch Cursor permissions for a project + * + * @param projectPath - Optional path to the project + * @param enabled - Whether to enable the query + * @returns Query result with permissions data + * + * @example + * ```tsx + * const { data: permissions, isLoading, refetch } = useCursorPermissions(projectPath); + * ``` + */ +export function useCursorPermissionsQuery(projectPath?: string, enabled = true) { + return useQuery({ + queryKey: queryKeys.cursorPermissions.permissions(projectPath), + queryFn: async (): Promise => { + const api = getHttpApiClient(); + const result = await api.setup.getCursorPermissions(projectPath); + + if (!result.success) { + throw new Error(result.error || 'Failed to load permissions'); + } + + return { + activeProfile: result.activeProfile || null, + effectivePermissions: result.effectivePermissions || null, + hasProjectConfig: result.hasProjectConfig || false, + availableProfiles: result.availableProfiles || [], + }; + }, + enabled, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-features.ts b/apps/ui/src/hooks/queries/use-features.ts new file mode 100644 index 00000000..89a67987 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-features.ts @@ -0,0 +1,127 @@ +/** + * Features Query Hooks + * + * React Query hooks for fetching and managing features data. + * These hooks replace manual useState/useEffect patterns with + * automatic caching, deduplication, and background refetching. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { Feature } from '@/store/app-store'; + +/** + * Fetch all features for a project + * + * @param projectPath - Path to the project + * @returns Query result with features array + * + * @example + * ```tsx + * const { data: features, isLoading, error } = useFeatures(currentProject?.path); + * ``` + */ +export function useFeatures(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.features.all(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.features?.getAll(projectPath); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch features'); + } + return (result.features ?? []) as Feature[]; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.FEATURES, + }); +} + +interface UseFeatureOptions { + enabled?: boolean; + /** Override polling interval (ms). Use false to disable polling. */ + pollingInterval?: number | false; +} + +/** + * Fetch a single feature by ID + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature to fetch + * @param options - Query options including enabled and polling interval + * @returns Query result with single feature + */ +export function useFeature( + projectPath: string | undefined, + featureId: string | undefined, + options: UseFeatureOptions = {} +) { + const { enabled = true, pollingInterval } = options; + + return useQuery({ + queryKey: queryKeys.features.single(projectPath ?? '', featureId ?? ''), + queryFn: async (): Promise => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.features?.get(projectPath, featureId); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch feature'); + } + return (result.feature as Feature) ?? null; + }, + enabled: !!projectPath && !!featureId && enabled, + staleTime: STALE_TIMES.FEATURES, + refetchInterval: pollingInterval, + }); +} + +interface UseAgentOutputOptions { + enabled?: boolean; + /** Override polling interval (ms). Use false to disable polling. */ + pollingInterval?: number | false; +} + +/** + * Fetch agent output for a feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @param options - Query options including enabled and polling interval + * @returns Query result with agent output string + */ +export function useAgentOutput( + projectPath: string | undefined, + featureId: string | undefined, + options: UseAgentOutputOptions = {} +) { + const { enabled = true, pollingInterval } = options; + + return useQuery({ + queryKey: queryKeys.features.agentOutput(projectPath ?? '', featureId ?? ''), + queryFn: async (): Promise => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.features?.getAgentOutput(projectPath, featureId); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch agent output'); + } + return result.content ?? ''; + }, + enabled: !!projectPath && !!featureId && enabled, + staleTime: STALE_TIMES.AGENT_OUTPUT, + // Use provided polling interval or default behavior + refetchInterval: + pollingInterval !== undefined + ? pollingInterval + : (query) => { + // Only poll if we have data and it's not empty (indicating active task) + if (query.state.data && query.state.data.length > 0) { + return 5000; // 5 seconds + } + return false; + }, + }); +} diff --git a/apps/ui/src/hooks/queries/use-git.ts b/apps/ui/src/hooks/queries/use-git.ts new file mode 100644 index 00000000..ef4be5ca --- /dev/null +++ b/apps/ui/src/hooks/queries/use-git.ts @@ -0,0 +1,37 @@ +/** + * Git Query Hooks + * + * React Query hooks for git operations. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +/** + * Fetch git diffs for a project (main project, not worktree) + * + * @param projectPath - Path to the project + * @param enabled - Whether to enable the query + * @returns Query result with files and diff content + */ +export function useGitDiffs(projectPath: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.git.diffs(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.git.getDiffs(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch diffs'); + } + return { + files: result.files ?? [], + diff: result.diff ?? '', + }; + }, + enabled: !!projectPath && enabled, + staleTime: STALE_TIMES.WORKTREES, + }); +} diff --git a/apps/ui/src/hooks/queries/use-github.ts b/apps/ui/src/hooks/queries/use-github.ts new file mode 100644 index 00000000..47c3de7c --- /dev/null +++ b/apps/ui/src/hooks/queries/use-github.ts @@ -0,0 +1,184 @@ +/** + * GitHub Query Hooks + * + * React Query hooks for fetching GitHub issues, PRs, and validations. + */ + +import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { GitHubIssue, GitHubPR, GitHubComment, IssueValidation } from '@/lib/electron'; + +interface GitHubIssuesResult { + openIssues: GitHubIssue[]; + closedIssues: GitHubIssue[]; +} + +interface GitHubPRsResult { + openPRs: GitHubPR[]; + mergedPRs: GitHubPR[]; +} + +/** + * Fetch GitHub issues for a project + * + * @param projectPath - Path to the project + * @returns Query result with open and closed issues + * + * @example + * ```tsx + * const { data, isLoading } = useGitHubIssues(currentProject?.path); + * const { openIssues, closedIssues } = data ?? { openIssues: [], closedIssues: [] }; + * ``` + */ +export function useGitHubIssues(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.github.issues(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.listIssues(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch issues'); + } + return { + openIssues: result.openIssues ?? [], + closedIssues: result.closedIssues ?? [], + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Fetch GitHub PRs for a project + * + * @param projectPath - Path to the project + * @returns Query result with open and merged PRs + */ +export function useGitHubPRs(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.github.prs(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.listPRs(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch PRs'); + } + return { + openPRs: result.openPRs ?? [], + mergedPRs: result.mergedPRs ?? [], + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Fetch GitHub validations for a project + * + * @param projectPath - Path to the project + * @param issueNumber - Optional issue number to filter by + * @returns Query result with validations + */ +export function useGitHubValidations(projectPath: string | undefined, issueNumber?: number) { + return useQuery({ + queryKey: issueNumber + ? queryKeys.github.validation(projectPath ?? '', issueNumber) + : queryKeys.github.validations(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.getValidations(projectPath, issueNumber); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch validations'); + } + return result.validations ?? []; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Check GitHub remote for a project + * + * @param projectPath - Path to the project + * @returns Query result with remote info + */ +export function useGitHubRemote(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.github.remote(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.checkRemote(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to check remote'); + } + return { + hasRemote: result.hasRemote ?? false, + owner: result.owner, + repo: result.repo, + url: result.url, + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Fetch comments for a GitHub issue with pagination support + * + * Uses useInfiniteQuery for proper "load more" pagination. + * + * @param projectPath - Path to the project + * @param issueNumber - Issue number + * @returns Infinite query result with comments and pagination helpers + * + * @example + * ```tsx + * const { + * data, + * isLoading, + * isFetchingNextPage, + * hasNextPage, + * fetchNextPage, + * refetch, + * } = useGitHubIssueComments(projectPath, issueNumber); + * + * // Get all comments flattened + * const comments = data?.pages.flatMap(page => page.comments) ?? []; + * ``` + */ +export function useGitHubIssueComments( + projectPath: string | undefined, + issueNumber: number | undefined +) { + return useInfiniteQuery({ + queryKey: queryKeys.github.issueComments(projectPath ?? '', issueNumber ?? 0), + queryFn: async ({ pageParam }: { pageParam: string | undefined }) => { + if (!projectPath || !issueNumber) throw new Error('Missing project path or issue number'); + const api = getElectronAPI(); + const result = await api.github.getIssueComments(projectPath, issueNumber, pageParam); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch comments'); + } + return { + comments: (result.comments ?? []) as GitHubComment[], + totalCount: result.totalCount ?? 0, + hasNextPage: result.hasNextPage ?? false, + endCursor: result.endCursor as string | undefined, + }; + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.endCursor : undefined), + enabled: !!projectPath && !!issueNumber, + staleTime: STALE_TIMES.GITHUB, + }); +} diff --git a/apps/ui/src/hooks/queries/use-ideation.ts b/apps/ui/src/hooks/queries/use-ideation.ts new file mode 100644 index 00000000..aa2bd023 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-ideation.ts @@ -0,0 +1,86 @@ +/** + * Ideation Query Hooks + * + * React Query hooks for fetching ideation prompts and ideas. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +/** + * Fetch ideation prompts + * + * @returns Query result with prompts and categories + * + * @example + * ```tsx + * const { data, isLoading, error } = useIdeationPrompts(); + * const { prompts, categories } = data ?? { prompts: [], categories: [] }; + * ``` + */ +export function useIdeationPrompts() { + return useQuery({ + queryKey: queryKeys.ideation.prompts(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.ideation?.getPrompts(); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch prompts'); + } + return { + prompts: result.prompts ?? [], + categories: result.categories ?? [], + }; + }, + staleTime: STALE_TIMES.SETTINGS, // Prompts rarely change + }); +} + +/** + * Fetch ideas for a project + * + * @param projectPath - Path to the project + * @returns Query result with ideas array + */ +export function useIdeas(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.ideation.ideas(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.ideation?.listIdeas(projectPath); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch ideas'); + } + return result.ideas ?? []; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.FEATURES, + }); +} + +/** + * Fetch a single idea by ID + * + * @param projectPath - Path to the project + * @param ideaId - ID of the idea + * @returns Query result with single idea + */ +export function useIdea(projectPath: string | undefined, ideaId: string | undefined) { + return useQuery({ + queryKey: queryKeys.ideation.idea(projectPath ?? '', ideaId ?? ''), + queryFn: async () => { + if (!projectPath || !ideaId) throw new Error('Missing project path or idea ID'); + const api = getElectronAPI(); + const result = await api.ideation?.getIdea(projectPath, ideaId); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch idea'); + } + return result.idea; + }, + enabled: !!projectPath && !!ideaId, + staleTime: STALE_TIMES.FEATURES, + }); +} diff --git a/apps/ui/src/hooks/queries/use-models.ts b/apps/ui/src/hooks/queries/use-models.ts new file mode 100644 index 00000000..d917492b --- /dev/null +++ b/apps/ui/src/hooks/queries/use-models.ts @@ -0,0 +1,134 @@ +/** + * Models Query Hooks + * + * React Query hooks for fetching available AI models. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface CodexModel { + id: string; + label: string; + description: string; + hasThinking: boolean; + supportsVision: boolean; + tier: 'premium' | 'standard' | 'basic'; + isDefault: boolean; +} + +interface OpencodeModel { + id: string; + name: string; + modelString: string; + provider: string; + description: string; + supportsTools: boolean; + supportsVision: boolean; + tier: string; + default?: boolean; +} + +/** + * Fetch available models + * + * @returns Query result with available models + */ +export function useAvailableModels() { + return useQuery({ + queryKey: queryKeys.models.available(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.model.getAvailable(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch available models'); + } + return result.models ?? []; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch Codex models + * + * @param refresh - Force refresh from server + * @returns Query result with Codex models + */ +export function useCodexModels(refresh = false) { + return useQuery({ + queryKey: queryKeys.models.codex(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.codex.getModels(refresh); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Codex models'); + } + return (result.models ?? []) as CodexModel[]; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch OpenCode models + * + * @param refresh - Force refresh from server + * @returns Query result with OpenCode models + */ +export function useOpencodeModels(refresh = false) { + return useQuery({ + queryKey: queryKeys.models.opencode(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.setup.getOpencodeModels(refresh); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch OpenCode models'); + } + return (result.models ?? []) as OpencodeModel[]; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch OpenCode providers + * + * @returns Query result with OpenCode providers + */ +export function useOpencodeProviders() { + return useQuery({ + queryKey: queryKeys.models.opencodeProviders(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getOpencodeProviders(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch OpenCode providers'); + } + return result.providers ?? []; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch model providers status + * + * @returns Query result with provider status + */ +export function useModelProviders() { + return useQuery({ + queryKey: queryKeys.models.providers(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.model.checkProviders(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch providers'); + } + return result.providers ?? {}; + }, + staleTime: STALE_TIMES.MODELS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-pipeline.ts b/apps/ui/src/hooks/queries/use-pipeline.ts new file mode 100644 index 00000000..916810d6 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-pipeline.ts @@ -0,0 +1,39 @@ +/** + * Pipeline Query Hooks + * + * React Query hooks for fetching pipeline configuration. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { PipelineConfig } from '@/store/app-store'; + +/** + * Fetch pipeline config for a project + * + * @param projectPath - Path to the project + * @returns Query result with pipeline config + * + * @example + * ```tsx + * const { data: pipelineConfig, isLoading } = usePipelineConfig(currentProject?.path); + * ``` + */ +export function usePipelineConfig(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.pipeline.config(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getHttpApiClient(); + const result = await api.pipeline.getConfig(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch pipeline config'); + } + return result.config ?? null; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-running-agents.ts b/apps/ui/src/hooks/queries/use-running-agents.ts new file mode 100644 index 00000000..a661d9c3 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-running-agents.ts @@ -0,0 +1,61 @@ +/** + * Running Agents Query Hook + * + * React Query hook for fetching currently running agents. + * This data is invalidated by WebSocket events when agents start/stop. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI, type RunningAgent } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface RunningAgentsResult { + agents: RunningAgent[]; + count: number; +} + +/** + * Fetch all currently running agents + * + * @returns Query result with running agents and total count + * + * @example + * ```tsx + * const { data, isLoading } = useRunningAgents(); + * const { agents, count } = data ?? { agents: [], count: 0 }; + * ``` + */ +export function useRunningAgents() { + return useQuery({ + queryKey: queryKeys.runningAgents.all(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.runningAgents.getAll(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch running agents'); + } + return { + agents: result.runningAgents ?? [], + count: result.totalCount ?? 0, + }; + }, + staleTime: STALE_TIMES.RUNNING_AGENTS, + // Note: Don't use refetchInterval here - rely on WebSocket invalidation + // for real-time updates instead of polling + }); +} + +/** + * Get running agents count + * This is a selector that derives count from the main query + * + * @returns Query result with just the count + */ +export function useRunningAgentsCount() { + const query = useRunningAgents(); + return { + ...query, + data: query.data?.count ?? 0, + }; +} diff --git a/apps/ui/src/hooks/queries/use-sessions.ts b/apps/ui/src/hooks/queries/use-sessions.ts new file mode 100644 index 00000000..001968e1 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-sessions.ts @@ -0,0 +1,86 @@ +/** + * Sessions Query Hooks + * + * React Query hooks for fetching session data. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { SessionListItem } from '@/types/electron'; + +/** + * Fetch all sessions + * + * @param includeArchived - Whether to include archived sessions + * @returns Query result with sessions array + * + * @example + * ```tsx + * const { data: sessions, isLoading } = useSessions(false); + * ``` + */ +export function useSessions(includeArchived = false) { + return useQuery({ + queryKey: queryKeys.sessions.all(includeArchived), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.sessions.list(includeArchived); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch sessions'); + } + return result.sessions ?? []; + }, + staleTime: STALE_TIMES.SESSIONS, + }); +} + +/** + * Fetch session history + * + * @param sessionId - ID of the session + * @returns Query result with session messages + */ +export function useSessionHistory(sessionId: string | undefined) { + return useQuery({ + queryKey: queryKeys.sessions.history(sessionId ?? ''), + queryFn: async () => { + if (!sessionId) throw new Error('No session ID'); + const api = getElectronAPI(); + const result = await api.agent.getHistory(sessionId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch session history'); + } + return { + messages: result.messages ?? [], + isRunning: result.isRunning ?? false, + }; + }, + enabled: !!sessionId, + staleTime: STALE_TIMES.FEATURES, // Session history changes during conversations + }); +} + +/** + * Fetch session message queue + * + * @param sessionId - ID of the session + * @returns Query result with queued messages + */ +export function useSessionQueue(sessionId: string | undefined) { + return useQuery({ + queryKey: queryKeys.sessions.queue(sessionId ?? ''), + queryFn: async () => { + if (!sessionId) throw new Error('No session ID'); + const api = getElectronAPI(); + const result = await api.agent.queueList(sessionId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch queue'); + } + return result.queue ?? []; + }, + enabled: !!sessionId, + staleTime: STALE_TIMES.RUNNING_AGENTS, // Queue changes frequently during use + }); +} diff --git a/apps/ui/src/hooks/queries/use-settings.ts b/apps/ui/src/hooks/queries/use-settings.ts new file mode 100644 index 00000000..e528bc63 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-settings.ts @@ -0,0 +1,122 @@ +/** + * Settings Query Hooks + * + * React Query hooks for fetching global and project settings. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { GlobalSettings, ProjectSettings } from '@automaker/types'; + +/** + * Fetch global settings + * + * @returns Query result with global settings + * + * @example + * ```tsx + * const { data: settings, isLoading } = useGlobalSettings(); + * ``` + */ +export function useGlobalSettings() { + return useQuery({ + queryKey: queryKeys.settings.global(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.settings.getGlobal(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch global settings'); + } + return result.settings as GlobalSettings; + }, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch project-specific settings + * + * @param projectPath - Path to the project + * @returns Query result with project settings + */ +export function useProjectSettings(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.settings.project(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.settings.getProject(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch project settings'); + } + return result.settings as ProjectSettings; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch settings status (migration status, etc.) + * + * @returns Query result with settings status + */ +export function useSettingsStatus() { + return useQuery({ + queryKey: queryKeys.settings.status(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.settings.getStatus(); + return result; + }, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch credentials status (masked API keys) + * + * @returns Query result with credentials info + */ +export function useCredentials() { + return useQuery({ + queryKey: queryKeys.settings.credentials(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.settings.getCredentials(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch credentials'); + } + return result.credentials; + }, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Discover agents for a project + * + * @param projectPath - Path to the project + * @param sources - Sources to search ('user' | 'project') + * @returns Query result with discovered agents + */ +export function useDiscoveredAgents( + projectPath: string | undefined, + sources?: Array<'user' | 'project'> +) { + return useQuery({ + queryKey: queryKeys.settings.agents(projectPath ?? ''), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.settings.discoverAgents(projectPath, sources); + if (!result.success) { + throw new Error(result.error || 'Failed to discover agents'); + } + return result.agents ?? []; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-spec.ts b/apps/ui/src/hooks/queries/use-spec.ts new file mode 100644 index 00000000..c81dea34 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-spec.ts @@ -0,0 +1,103 @@ +/** + * Spec Query Hooks + * + * React Query hooks for fetching spec file content and regeneration status. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface SpecFileResult { + content: string; + exists: boolean; +} + +interface SpecRegenerationStatusResult { + isRunning: boolean; + currentPhase?: string; +} + +/** + * Fetch spec file content for a project + * + * @param projectPath - Path to the project + * @returns Query result with spec content and existence flag + * + * @example + * ```tsx + * const { data, isLoading } = useSpecFile(currentProject?.path); + * if (data?.exists) { + * console.log(data.content); + * } + * ``` + */ +export function useSpecFile(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.spec.file(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + + const api = getElectronAPI(); + const result = await api.readFile(`${projectPath}/.automaker/app_spec.txt`); + + if (result.success && result.content) { + return { + content: result.content, + exists: true, + }; + } + + return { + content: '', + exists: false, + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Check spec regeneration status for a project + * + * @param projectPath - Path to the project + * @param enabled - Whether to enable the query (useful during regeneration) + * @returns Query result with regeneration status + * + * @example + * ```tsx + * const { data } = useSpecRegenerationStatus(projectPath, isRegenerating); + * if (data?.isRunning) { + * // Show loading indicator + * } + * ``` + */ +export function useSpecRegenerationStatus(projectPath: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.specRegeneration.status(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + + const api = getElectronAPI(); + if (!api.specRegeneration) { + return { isRunning: false }; + } + + const status = await api.specRegeneration.status(projectPath); + + if (status.success) { + return { + isRunning: status.isRunning ?? false, + currentPhase: status.currentPhase, + }; + } + + return { isRunning: false }; + }, + enabled: !!projectPath && enabled, + staleTime: 5000, // Check every 5 seconds when active + refetchInterval: enabled ? 5000 : false, + }); +} diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts new file mode 100644 index 00000000..38de9bb8 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -0,0 +1,77 @@ +/** + * Usage Query Hooks + * + * React Query hooks for fetching Claude and Codex API usage data. + * These hooks include automatic polling for real-time usage updates. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { ClaudeUsage, CodexUsage } from '@/store/app-store'; + +/** Polling interval for usage data (60 seconds) */ +const USAGE_POLLING_INTERVAL = 60 * 1000; + +/** + * Fetch Claude API usage data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Claude usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useClaudeUsage(isPopoverOpen); + * ``` + */ +export function useClaudeUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.claude(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.claude.getUsage(); + // Check if result is an error response + if ('error' in result) { + throw new Error(result.message || result.error); + } + return result; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + }); +} + +/** + * Fetch Codex API usage data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Codex usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useCodexUsage(isPopoverOpen); + * ``` + */ +export function useCodexUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.codex(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.codex.getUsage(); + // Check if result is an error response + if ('error' in result) { + throw new Error(result.message || result.error); + } + return result; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + }); +} diff --git a/apps/ui/src/hooks/queries/use-workspace.ts b/apps/ui/src/hooks/queries/use-workspace.ts new file mode 100644 index 00000000..2001e2b7 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-workspace.ts @@ -0,0 +1,42 @@ +/** + * Workspace Query Hooks + * + * React Query hooks for workspace operations. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface WorkspaceDirectory { + name: string; + path: string; +} + +/** + * Fetch workspace directories + * + * @param enabled - Whether to enable the query + * @returns Query result with directories + * + * @example + * ```tsx + * const { data: directories, isLoading, error } = useWorkspaceDirectories(open); + * ``` + */ +export function useWorkspaceDirectories(enabled = true) { + return useQuery({ + queryKey: queryKeys.workspace.directories(), + queryFn: async (): Promise => { + const api = getHttpApiClient(); + const result = await api.workspace.getDirectories(); + if (!result.success) { + throw new Error(result.error || 'Failed to load directories'); + } + return result.directories ?? []; + }, + enabled, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts new file mode 100644 index 00000000..7cafbddb --- /dev/null +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -0,0 +1,252 @@ +/** + * Worktrees Query Hooks + * + * React Query hooks for fetching worktree data. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; + featureId?: string; + linkedToBranch?: string; +} + +interface RemovedWorktree { + path: string; + branch: string; +} + +interface WorktreesResult { + worktrees: WorktreeInfo[]; + removedWorktrees: RemovedWorktree[]; +} + +/** + * Fetch all worktrees for a project + * + * @param projectPath - Path to the project + * @param includeDetails - Whether to include detailed info (default: true) + * @returns Query result with worktrees array and removed worktrees + * + * @example + * ```tsx + * const { data, isLoading, refetch } = useWorktrees(currentProject?.path); + * const worktrees = data?.worktrees ?? []; + * ``` + */ +export function useWorktrees(projectPath: string | undefined, includeDetails = true) { + return useQuery({ + queryKey: queryKeys.worktrees.all(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.worktree.listAll(projectPath, includeDetails); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch worktrees'); + } + return { + worktrees: result.worktrees ?? [], + removedWorktrees: result.removedWorktrees ?? [], + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +/** + * Fetch worktree info for a specific feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @returns Query result with worktree info + */ +export function useWorktreeInfo(projectPath: string | undefined, featureId: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.single(projectPath ?? '', featureId ?? ''), + queryFn: async () => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.worktree.getInfo(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch worktree info'); + } + return result; + }, + enabled: !!projectPath && !!featureId, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +/** + * Fetch worktree status for a specific feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @returns Query result with worktree status + */ +export function useWorktreeStatus(projectPath: string | undefined, featureId: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.status(projectPath ?? '', featureId ?? ''), + queryFn: async () => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.worktree.getStatus(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch worktree status'); + } + return result; + }, + enabled: !!projectPath && !!featureId, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +/** + * Fetch worktree diffs for a specific feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @returns Query result with files and diff content + */ +export function useWorktreeDiffs(projectPath: string | undefined, featureId: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.diffs(projectPath ?? '', featureId ?? ''), + queryFn: async () => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.worktree.getDiffs(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch diffs'); + } + return { + files: result.files ?? [], + diff: result.diff ?? '', + }; + }, + enabled: !!projectPath && !!featureId, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +interface BranchInfo { + name: string; + isCurrent: boolean; + isRemote?: boolean; + lastCommit?: string; + upstream?: string; +} + +interface BranchesResult { + branches: BranchInfo[]; + aheadCount: number; + behindCount: number; + isGitRepo: boolean; + hasCommits: boolean; +} + +/** + * Fetch available branches for a worktree + * + * @param worktreePath - Path to the worktree + * @param includeRemote - Whether to include remote branches + * @returns Query result with branches, ahead/behind counts, and git repo status + */ +export function useWorktreeBranches(worktreePath: string | undefined, includeRemote = false) { + return useQuery({ + queryKey: queryKeys.worktrees.branches(worktreePath ?? ''), + queryFn: async (): Promise => { + if (!worktreePath) throw new Error('No worktree path'); + const api = getElectronAPI(); + const result = await api.worktree.listBranches(worktreePath, includeRemote); + + // Handle special git status codes + if (result.code === 'NOT_GIT_REPO') { + return { + branches: [], + aheadCount: 0, + behindCount: 0, + isGitRepo: false, + hasCommits: false, + }; + } + if (result.code === 'NO_COMMITS') { + return { + branches: [], + aheadCount: 0, + behindCount: 0, + isGitRepo: true, + hasCommits: false, + }; + } + + if (!result.success) { + throw new Error(result.error || 'Failed to fetch branches'); + } + + return { + branches: result.result?.branches ?? [], + aheadCount: result.result?.aheadCount ?? 0, + behindCount: result.result?.behindCount ?? 0, + isGitRepo: true, + hasCommits: true, + }; + }, + enabled: !!worktreePath, + staleTime: STALE_TIMES.WORKTREES, + }); +} + +/** + * Fetch init script for a project + * + * @param projectPath - Path to the project + * @returns Query result with init script content + */ +export function useWorktreeInitScript(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.initScript(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.worktree.getInitScript(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch init script'); + } + return { + exists: result.exists ?? false, + content: result.content ?? '', + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch available editors + * + * @returns Query result with available editors + */ +export function useAvailableEditors() { + return useQuery({ + queryKey: queryKeys.worktrees.editors(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.worktree.getAvailableEditors(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch editors'); + } + return result.editors ?? []; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +}