feat(ui): add React Query mutation hooks

- Add feature mutations (create, update, delete with optimistic updates)
- Add auto-mode mutations (start, stop, approve plan)
- Add worktree mutations (create, delete, checkout, switch branch)
- Add settings mutations (update global/project, validate API keys)
- Add GitHub mutations (create PR, validate PR)
- Add cursor permissions mutations (apply profile, copy config)
- Add spec mutations (generate, update, save)
- Add pipeline mutations (toggle, update config)
- Add session mutations with cache invalidation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-15 16:20:38 +01:00
parent 2bc931a8b0
commit 845674128e
9 changed files with 1859 additions and 0 deletions

View File

@@ -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';

View File

@@ -0,0 +1,373 @@
/**
* Auto Mode Mutations
*
* React Query mutations for auto mode operations like running features,
* stopping features, and plan approval.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { toast } from 'sonner';
/**
* Start running a feature in auto mode
*
* @param projectPath - Path to the project
* @returns Mutation for starting a feature
*
* @example
* ```tsx
* const startFeature = useStartFeature(projectPath);
* startFeature.mutate({ featureId: 'abc123', useWorktrees: true });
* ```
*/
export function useStartFeature(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
featureId,
useWorktrees,
worktreePath,
}: {
featureId: string;
useWorktrees?: boolean;
worktreePath?: string;
}) => {
const api = getElectronAPI();
const result = await api.autoMode.runFeature(
projectPath,
featureId,
useWorktrees,
worktreePath
);
if (!result.success) {
throw new Error(result.error || 'Failed to start feature');
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
},
onError: (error: Error) => {
toast.error('Failed to start feature', {
description: error.message,
});
},
});
}
/**
* Resume a paused or interrupted feature
*
* @param projectPath - Path to the project
* @returns Mutation for resuming a feature
*/
export function useResumeFeature(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
featureId,
useWorktrees,
}: {
featureId: string;
useWorktrees?: boolean;
}) => {
const api = getElectronAPI();
const result = await api.autoMode.resumeFeature(projectPath, featureId, useWorktrees);
if (!result.success) {
throw new Error(result.error || 'Failed to resume feature');
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
},
onError: (error: Error) => {
toast.error('Failed to resume feature', {
description: error.message,
});
},
});
}
/**
* Stop a running feature
*
* @returns Mutation for stopping a feature
*/
export function useStopFeature() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (featureId: string) => {
const api = getElectronAPI();
const result = await api.autoMode.stopFeature(featureId);
if (!result.success) {
throw new Error(result.error || 'Failed to stop feature');
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
toast.success('Feature stopped');
},
onError: (error: Error) => {
toast.error('Failed to stop feature', {
description: error.message,
});
},
});
}
/**
* Verify a completed feature
*
* @param projectPath - Path to the project
* @returns Mutation for verifying a feature
*/
export function useVerifyFeature(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (featureId: string) => {
const api = getElectronAPI();
const result = await api.autoMode.verifyFeature(projectPath, featureId);
if (!result.success) {
throw new Error(result.error || 'Failed to verify feature');
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
},
onError: (error: Error) => {
toast.error('Failed to verify feature', {
description: error.message,
});
},
});
}
/**
* Approve or reject a plan
*
* @param projectPath - Path to the project
* @returns Mutation for plan approval
*
* @example
* ```tsx
* const approvePlan = useApprovePlan(projectPath);
* approvePlan.mutate({ featureId: 'abc', approved: true });
* ```
*/
export function useApprovePlan(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
featureId,
approved,
editedPlan,
feedback,
}: {
featureId: string;
approved: boolean;
editedPlan?: string;
feedback?: string;
}) => {
const api = getElectronAPI();
const result = await api.autoMode.approvePlan(
projectPath,
featureId,
approved,
editedPlan,
feedback
);
if (!result.success) {
throw new Error(result.error || 'Failed to submit plan decision');
}
return result;
},
onSuccess: (_, { approved }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
if (approved) {
toast.success('Plan approved');
} else {
toast.info('Plan rejected');
}
},
onError: (error: Error) => {
toast.error('Failed to submit plan decision', {
description: error.message,
});
},
});
}
/**
* Send a follow-up prompt to a feature
*
* @param projectPath - Path to the project
* @returns Mutation for sending follow-up
*/
export function useFollowUpFeature(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
featureId,
prompt,
imagePaths,
useWorktrees,
}: {
featureId: string;
prompt: string;
imagePaths?: string[];
useWorktrees?: boolean;
}) => {
const api = getElectronAPI();
const result = await api.autoMode.followUpFeature(
projectPath,
featureId,
prompt,
imagePaths,
useWorktrees
);
if (!result.success) {
throw new Error(result.error || 'Failed to send follow-up');
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
},
onError: (error: Error) => {
toast.error('Failed to send follow-up', {
description: error.message,
});
},
});
}
/**
* Commit feature changes
*
* @param projectPath - Path to the project
* @returns Mutation for committing feature
*/
export function useCommitFeature(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (featureId: string) => {
const api = getElectronAPI();
const result = await api.autoMode.commitFeature(projectPath, featureId);
if (!result.success) {
throw new Error(result.error || 'Failed to commit changes');
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) });
toast.success('Changes committed');
},
onError: (error: Error) => {
toast.error('Failed to commit changes', {
description: error.message,
});
},
});
}
/**
* Analyze project structure
*
* @returns Mutation for project analysis
*/
export function useAnalyzeProject() {
return useMutation({
mutationFn: async (projectPath: string) => {
const api = getElectronAPI();
const result = await api.autoMode.analyzeProject(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to analyze project');
}
return result;
},
onSuccess: () => {
toast.success('Project analysis started');
},
onError: (error: Error) => {
toast.error('Failed to analyze project', {
description: error.message,
});
},
});
}
/**
* Start auto mode for all pending features
*
* @param projectPath - Path to the project
* @returns Mutation for starting auto mode
*/
export function useStartAutoMode(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (maxConcurrency?: number) => {
const api = getElectronAPI();
const result = await api.autoMode.start(projectPath, maxConcurrency);
if (!result.success) {
throw new Error(result.error || 'Failed to start auto mode');
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
toast.success('Auto mode started');
},
onError: (error: Error) => {
toast.error('Failed to start auto mode', {
description: error.message,
});
},
});
}
/**
* Stop auto mode for all features
*
* @param projectPath - Path to the project
* @returns Mutation for stopping auto mode
*/
export function useStopAutoMode(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const api = getElectronAPI();
const result = await api.autoMode.stop(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to stop auto mode');
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
toast.success('Auto mode stopped');
},
onError: (error: Error) => {
toast.error('Failed to stop auto mode', {
description: error.message,
});
},
});
}

View File

@@ -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',
});
},
});
}

View File

@@ -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<Feature>;
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<Feature[]>(
queryKeys.features.all(projectPath)
);
// Optimistically update the cache
if (previousFeatures) {
queryClient.setQueryData<Feature[]>(
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<Feature[]>(
queryKeys.features.all(projectPath)
);
if (previousFeatures) {
queryClient.setQueryData<Feature[]>(
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<Feature> }>) => {
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<Feature[]>(
queryKeys.features.all(projectPath)
);
if (previousFeatures) {
const updatesMap = new Map(updates.map((u) => [u.featureId, u.updates]));
queryClient.setQueryData<Feature[]>(
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),
});
},
});
}

View File

@@ -0,0 +1,159 @@
/**
* GitHub Mutation Hooks
*
* React Query mutations for GitHub operations like validating issues.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI, GitHubIssue, GitHubComment } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { toast } from 'sonner';
import type { LinkedPRInfo, ModelId } from '@automaker/types';
/**
* Input for validating a GitHub issue
*/
interface ValidateIssueInput {
issue: GitHubIssue;
model?: ModelId;
thinkingLevel?: number;
reasoningEffort?: string;
comments?: GitHubComment[];
linkedPRs?: LinkedPRInfo[];
}
/**
* Validate a GitHub issue with AI
*
* This mutation triggers an async validation process. Results are delivered
* via WebSocket events (issue_validation_complete, issue_validation_error).
*
* @param projectPath - Path to the project
* @returns Mutation for validating issues
*
* @example
* ```tsx
* const validateMutation = useValidateIssue(projectPath);
*
* validateMutation.mutate({
* issue,
* model: 'sonnet',
* comments,
* linkedPRs,
* });
* ```
*/
export function useValidateIssue(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: ValidateIssueInput) => {
const { issue, model, thinkingLevel, reasoningEffort, comments, linkedPRs } = input;
const api = getElectronAPI();
if (!api.github?.validateIssue) {
throw new Error('Validation API not available');
}
const validationInput = {
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
comments,
linkedPRs,
};
const result = await api.github.validateIssue(
projectPath,
validationInput,
model,
thinkingLevel,
reasoningEffort
);
if (!result.success) {
throw new Error(result.error || 'Failed to start validation');
}
return { issueNumber: issue.number };
},
onSuccess: (_, variables) => {
toast.info(`Starting validation for issue #${variables.issue.number}`, {
description: 'You will be notified when the analysis is complete',
});
},
onError: (error) => {
toast.error('Failed to validate issue', {
description: error instanceof Error ? error.message : 'Unknown error',
});
},
// Note: We don't invalidate queries here because the actual result
// comes through WebSocket events which handle cache invalidation
});
}
/**
* Mark a validation as viewed
*
* @param projectPath - Path to the project
* @returns Mutation for marking validation as viewed
*
* @example
* ```tsx
* const markViewedMutation = useMarkValidationViewed(projectPath);
* markViewedMutation.mutate(issueNumber);
* ```
*/
export function useMarkValidationViewed(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (issueNumber: number) => {
const api = getElectronAPI();
if (!api.github?.markValidationViewed) {
throw new Error('Mark viewed API not available');
}
const result = await api.github.markValidationViewed(projectPath, issueNumber);
if (!result.success) {
throw new Error(result.error || 'Failed to mark as viewed');
}
return { issueNumber };
},
onSuccess: () => {
// Invalidate validations cache to refresh the viewed state
queryClient.invalidateQueries({
queryKey: queryKeys.github.validations(projectPath),
});
},
// Silent mutation - no toast needed for marking as viewed
});
}
/**
* Get running validation status
*
* @param projectPath - Path to the project
* @returns Mutation for getting validation status (returns running issue numbers)
*/
export function useGetValidationStatus(projectPath: string) {
return useMutation({
mutationFn: async () => {
const api = getElectronAPI();
if (!api.github?.getValidationStatus) {
throw new Error('Validation status API not available');
}
const result = await api.github.getValidationStatus(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to get validation status');
}
return result.runningIssues ?? [];
},
});
}

View File

@@ -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<GenerateSuggestionsResult> => {
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
});
}

View File

@@ -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<string, unknown>) => {
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<string, unknown>
| { projectPath: string; settings: Record<string, unknown> }
) => {
// Support both call patterns:
// 1. useUpdateProjectSettings(projectPath) then mutate(settings)
// 2. useUpdateProjectSettings() then mutate({ projectPath, settings })
let path: string;
let settings: Record<string, unknown>;
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<string, string>) => {
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,
});
},
});
}

View File

@@ -0,0 +1,179 @@
/**
* Spec Mutation Hooks
*
* React Query mutations for spec operations like creating, regenerating, and saving.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { toast } from 'sonner';
import type { FeatureCount } from '@/components/views/spec-view/types';
/**
* Input for creating a spec
*/
interface CreateSpecInput {
projectOverview: string;
generateFeatures: boolean;
analyzeProject: boolean;
featureCount?: FeatureCount;
}
/**
* Input for regenerating a spec
*/
interface RegenerateSpecInput {
projectDefinition: string;
generateFeatures: boolean;
analyzeProject: boolean;
featureCount?: FeatureCount;
}
/**
* Create a new spec for a project
*
* This mutation triggers an async spec creation process. Progress and completion
* are delivered via WebSocket events (spec_regeneration_progress, spec_regeneration_complete).
*
* @param projectPath - Path to the project
* @returns Mutation for creating specs
*
* @example
* ```tsx
* const createMutation = useCreateSpec(projectPath);
*
* createMutation.mutate({
* projectOverview: 'A todo app with...',
* generateFeatures: true,
* analyzeProject: true,
* featureCount: 50,
* });
* ```
*/
export function useCreateSpec(projectPath: string) {
return useMutation({
mutationFn: async (input: CreateSpecInput) => {
const { projectOverview, generateFeatures, analyzeProject, featureCount } = input;
const api = getElectronAPI();
if (!api.specRegeneration) {
throw new Error('Spec regeneration API not available');
}
const result = await api.specRegeneration.create(
projectPath,
projectOverview.trim(),
generateFeatures,
analyzeProject,
generateFeatures ? featureCount : undefined
);
if (!result.success) {
throw new Error(result.error || 'Failed to start spec creation');
}
return result;
},
// Toast/state updates are handled by the component since it tracks WebSocket events
});
}
/**
* Regenerate an existing spec
*
* @param projectPath - Path to the project
* @returns Mutation for regenerating specs
*/
export function useRegenerateSpec(projectPath: string) {
return useMutation({
mutationFn: async (input: RegenerateSpecInput) => {
const { projectDefinition, generateFeatures, analyzeProject, featureCount } = input;
const api = getElectronAPI();
if (!api.specRegeneration) {
throw new Error('Spec regeneration API not available');
}
const result = await api.specRegeneration.generate(
projectPath,
projectDefinition.trim(),
generateFeatures,
analyzeProject,
generateFeatures ? featureCount : undefined
);
if (!result.success) {
throw new Error(result.error || 'Failed to start spec regeneration');
}
return result;
},
});
}
/**
* Generate features from existing spec
*
* @param projectPath - Path to the project
* @returns Mutation for generating features
*/
export function useGenerateFeatures(projectPath: string) {
return useMutation({
mutationFn: async () => {
const api = getElectronAPI();
if (!api.specRegeneration) {
throw new Error('Spec regeneration API not available');
}
const result = await api.specRegeneration.generateFeatures(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to start feature generation');
}
return result;
},
});
}
/**
* Save spec file content
*
* @param projectPath - Path to the project
* @returns Mutation for saving spec
*
* @example
* ```tsx
* const saveMutation = useSaveSpec(projectPath);
*
* saveMutation.mutate(specContent, {
* onSuccess: () => setHasChanges(false),
* });
* ```
*/
export function useSaveSpec(projectPath: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (content: string) => {
const api = getElectronAPI();
await api.writeFile(`${projectPath}/.automaker/app_spec.txt`, content);
return { content };
},
onSuccess: () => {
// Invalidate spec file cache
queryClient.invalidateQueries({
queryKey: queryKeys.spec.file(projectPath),
});
toast.success('Spec saved');
},
onError: (error) => {
toast.error('Failed to save spec', {
description: error instanceof Error ? error.message : 'Unknown error',
});
},
});
}

View File

@@ -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,
});
},
});
}