refactor(ui): migrate remaining components to React Query

- Migrate workspace-picker-modal to useWorkspaceDirectories query
- Migrate session-manager to useSessions query
- Migrate git-diff-panel to useGitDiffs query
- Migrate prompt-list to useIdeationPrompts query
- Migrate spec-view hooks to useSpecFile query and spec mutations
- Migrate use-board-background-settings to useProjectSettings query
- Migrate use-guided-prompts to useIdeationPrompts query
- Migrate use-project-settings-loader to React Query
- Complete React Query migration across all components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-15 16:22:39 +01:00
parent 5fe7bcd378
commit c2fed78733
10 changed files with 308 additions and 434 deletions

View File

@@ -1,4 +1,3 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -9,7 +8,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useWorkspaceDirectories } from '@/hooks/queries';
interface WorkspaceDirectory {
name: string;
@@ -23,41 +22,15 @@ interface WorkspacePickerModalProps {
}
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
const [error, setError] = useState<string | null>(null);
const loadDirectories = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const client = getHttpApiClient();
const result = await client.workspace.getDirectories();
if (result.success && result.directories) {
setDirectories(result.directories);
} else {
setError(result.error || 'Failed to load directories');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directories');
} finally {
setIsLoading(false);
}
}, []);
// Load directories when modal opens
useEffect(() => {
if (open) {
loadDirectories();
}
}, [open, loadDirectories]);
// React Query hook - only fetch when modal is open
const { data: directories = [], isLoading, error, refetch } = useWorkspaceDirectories(open);
const handleSelect = (dir: WorkspaceDirectory) => {
onSelect(dir.path, dir.name);
};
const errorMessage = error instanceof Error ? error.message : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-card border-border max-w-lg max-h-[80vh] flex flex-col">
@@ -79,19 +52,19 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
</div>
)}
{error && !isLoading && (
{errorMessage && !isLoading && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-destructive" />
</div>
<p className="text-sm text-destructive">{error}</p>
<Button variant="secondary" size="sm" onClick={loadDirectories} className="mt-2">
<p className="text-sm text-destructive">{errorMessage}</p>
<Button variant="secondary" size="sm" onClick={() => refetch()} className="mt-2">
Try Again
</Button>
</div>
)}
{!isLoading && !error && directories.length === 0 && (
{!isLoading && !errorMessage && directories.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
<Folder className="w-6 h-6 text-muted-foreground" />
@@ -102,7 +75,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
</div>
)}
{!isLoading && !error && directories.length > 0 && (
{!isLoading && !errorMessage && directories.length > 0 && (
<div className="space-y-2">
{directories.map((dir) => (
<button

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const logger = createLogger('SessionManager');
@@ -22,6 +23,8 @@ import { cn } from '@/lib/utils';
import type { SessionListItem } from '@/types/electron';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { useSessions } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
@@ -102,7 +105,7 @@ export function SessionManager({
onQuickCreateRef,
}: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig();
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
@@ -113,8 +116,11 @@ export function SessionManager({
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
// Use React Query for sessions list - always include archived, filter client-side
const { data: sessions = [], refetch: refetchSessions } = useSessions(true);
// Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
const checkRunningSessions = useCallback(async (sessionList: SessionListItem[]) => {
const api = getElectronAPI();
if (!api?.agent) return;
@@ -134,26 +140,25 @@ export function SessionManager({
}
setRunningSessions(runningIds);
};
// Load sessions
const loadSessions = async () => {
const api = getElectronAPI();
if (!api?.sessions) return;
// Always load all sessions and filter client-side
const result = await api.sessions.list(true);
if (result.success && result.sessions) {
setSessions(result.sessions);
// Check running state for all sessions
await checkRunningSessions(result.sessions);
}
};
useEffect(() => {
loadSessions();
}, []);
// Helper to invalidate sessions cache and refetch
const invalidateSessions = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all(true) });
// Also check running state after invalidation
const result = await refetchSessions();
if (result.data) {
await checkRunningSessions(result.data);
}
}, [queryClient, refetchSessions, checkRunningSessions]);
// Check running state on initial load
useEffect(() => {
if (sessions.length > 0) {
checkRunningSessions(sessions);
}
}, [sessions.length > 0]); // Only run when sessions first load
// Periodically check running state for sessions (useful for detecting when agents finish)
useEffect(() => {
// Only poll if there are running sessions
@@ -166,7 +171,7 @@ export function SessionManager({
}, 3000); // Check every 3 seconds
return () => clearInterval(interval);
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
// Create new session with random name
const handleCreateSession = async () => {
@@ -180,7 +185,7 @@ export function SessionManager({
if (result.success && result.session?.id) {
setNewSessionName('');
setIsCreating(false);
await loadSessions();
await invalidateSessions();
onSelectSession(result.session.id);
}
};
@@ -195,7 +200,7 @@ export function SessionManager({
const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) {
await loadSessions();
await invalidateSessions();
onSelectSession(result.session.id);
}
};
@@ -222,7 +227,7 @@ export function SessionManager({
if (result.success) {
setEditingSessionId(null);
setEditingName('');
await loadSessions();
await invalidateSessions();
}
};
@@ -241,7 +246,7 @@ export function SessionManager({
if (currentSessionId === sessionId) {
onSelectSession(null);
}
await loadSessions();
await invalidateSessions();
} else {
logger.error('[SessionManager] Archive failed:', result.error);
}
@@ -261,7 +266,7 @@ export function SessionManager({
try {
const result = await api.sessions.unarchive(sessionId);
if (result.success) {
await loadSessions();
await invalidateSessions();
} else {
logger.error('[SessionManager] Unarchive failed:', result.error);
}
@@ -283,7 +288,7 @@ export function SessionManager({
const result = await api.sessions.delete(sessionId);
if (result.success) {
await loadSessions();
await invalidateSessions();
if (currentSessionId === sessionId) {
// Switch to another session or create a new one
const activeSessionsList = sessions.filter((s) => !s.isArchived);
@@ -305,7 +310,7 @@ export function SessionManager({
await api.sessions.delete(session.id);
}
await loadSessions();
await invalidateSessions();
setIsDeleteAllArchivedDialogOpen(false);
};

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
File,
@@ -15,6 +14,7 @@ import {
AlertCircle,
} from 'lucide-react';
import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import type { FileStatus } from '@/types/electron';
interface GitDiffPanelProps {
@@ -350,56 +350,44 @@ export function GitDiffPanel({
useWorktrees = false,
}: GitDiffPanelProps) {
const [isExpanded, setIsExpanded] = useState(!compact);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState<string>('');
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const loadDiffs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
// Use worktree diffs hook when worktrees are enabled and panel is expanded
// Pass undefined for featureId when not using worktrees to disable the query
const {
data: worktreeDiffsData,
isLoading: isLoadingWorktree,
error: worktreeError,
refetch: refetchWorktree,
} = useWorktreeDiffs(
useWorktrees && isExpanded ? projectPath : undefined,
useWorktrees && isExpanded ? featureId : undefined
);
// Use worktree API if worktrees are enabled, otherwise use git API for main project
if (useWorktrees) {
if (!api?.worktree?.getDiffs) {
throw new Error('Worktree API not available');
}
const result = await api.worktree.getDiffs(projectPath, featureId);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || '');
} else {
setError(result.error || 'Failed to load diffs');
}
} else {
// Use git API for main project diffs
if (!api?.git?.getDiffs) {
throw new Error('Git API not available');
}
const result = await api.git.getDiffs(projectPath);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || '');
} else {
setError(result.error || 'Failed to load diffs');
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load diffs');
} finally {
setIsLoading(false);
}
}, [projectPath, featureId, useWorktrees]);
// Use git diffs hook when worktrees are disabled and panel is expanded
const {
data: gitDiffsData,
isLoading: isLoadingGit,
error: gitError,
refetch: refetchGit,
} = useGitDiffs(projectPath, !useWorktrees && isExpanded);
// Load diffs when expanded
useEffect(() => {
if (isExpanded) {
loadDiffs();
}
}, [isExpanded, loadDiffs]);
// Select the appropriate data based on useWorktrees prop
const diffsData = useWorktrees ? worktreeDiffsData : gitDiffsData;
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
const queryError = useWorktrees ? worktreeError : gitError;
// Extract files and diff content from the data
const files: FileStatus[] = diffsData?.files ?? [];
const diffContent = diffsData?.diff ?? '';
const error = queryError
? queryError instanceof Error
? queryError.message
: 'Failed to load diffs'
: null;
// Refetch function
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);

View File

@@ -8,7 +8,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { useIdeationStore } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
@@ -27,6 +27,9 @@ export function PromptList({ category, onBack }: PromptListProps) {
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
const navigate = useNavigate();
// React Query mutation
const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
const {
getPromptsByCategory,
isLoading: isLoadingPrompts,
@@ -56,7 +59,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
return;
}
if (loadingPromptId || generatingPromptIds.has(prompt.id)) return;
if (loadingPromptId || generateMutation.isPending || generatingPromptIds.has(prompt.id)) return;
setLoadingPromptId(prompt.id);
@@ -68,42 +71,31 @@ export function PromptList({ category, onBack }: PromptListProps) {
toast.info(`Generating ideas for "${prompt.title}"...`);
setMode('dashboard');
try {
const api = getElectronAPI();
const result = await api.ideation?.generateSuggestions(
currentProject.path,
prompt.id,
category
);
if (result?.success && result.suggestions) {
updateJobStatus(jobId, 'ready', result.suggestions);
toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, {
duration: 10000,
action: {
label: 'View Ideas',
onClick: () => {
setMode('dashboard');
navigate({ to: '/ideation' });
generateMutation.mutate(
{ promptId: prompt.id, category },
{
onSuccess: (data) => {
updateJobStatus(jobId, 'ready', data.suggestions);
toast.success(`Generated ${data.suggestions.length} ideas for "${prompt.title}"`, {
duration: 10000,
action: {
label: 'View Ideas',
onClick: () => {
setMode('dashboard');
navigate({ to: '/ideation' });
},
},
},
});
} else {
updateJobStatus(
jobId,
'error',
undefined,
result?.error || 'Failed to generate suggestions'
);
toast.error(result?.error || 'Failed to generate suggestions');
});
setLoadingPromptId(null);
},
onError: (error) => {
console.error('Failed to generate suggestions:', error);
updateJobStatus(jobId, 'error', undefined, error.message);
toast.error(error.message);
setLoadingPromptId(null);
},
}
} catch (error) {
console.error('Failed to generate suggestions:', error);
updateJobStatus(jobId, 'error', undefined, (error as Error).message);
toast.error((error as Error).message);
} finally {
setLoadingPromptId(null);
}
);
};
return (

View File

@@ -10,6 +10,7 @@ import { createElement } from 'react';
import { SPEC_FILE_WRITE_DELAY, STATUS_CHECK_INTERVAL_MS } from '../constants';
import type { FeatureCount } from '../types';
import type { SpecRegenerationEvent } from '@/types/electron';
import { useCreateSpec, useRegenerateSpec, useGenerateFeatures } from '@/hooks/mutations';
interface UseSpecGenerationOptions {
loadSpec: () => Promise<void>;
@@ -18,6 +19,11 @@ interface UseSpecGenerationOptions {
export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const { currentProject } = useAppStore();
// React Query mutations
const createSpecMutation = useCreateSpec(currentProject?.path ?? '');
const regenerateSpecMutation = useRegenerateSpec(currentProject?.path ?? '');
const generateFeaturesMutation = useGenerateFeatures(currentProject?.path ?? '');
// Dialog visibility state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
@@ -404,47 +410,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
logsRef.current = '';
setLogs('');
logger.debug('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
logger.error('[useSpecGeneration] Spec regeneration not available');
setIsCreating(false);
return;
}
const result = await api.specRegeneration.create(
currentProject.path,
projectOverview.trim(),
generateFeatures,
analyzeProjectOnCreate,
generateFeatures ? featureCountOnCreate : undefined
);
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start spec creation:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
createSpecMutation.mutate(
{
projectOverview: projectOverview.trim(),
generateFeatures,
analyzeProject: analyzeProjectOnCreate,
featureCount: generateFeatures ? featureCountOnCreate : undefined,
},
{
onError: (error) => {
const errorMsg = error.message;
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
},
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
);
}, [
currentProject,
projectOverview,
generateFeatures,
analyzeProjectOnCreate,
featureCountOnCreate,
createSpecMutation,
]);
const handleRegenerate = useCallback(async () => {
@@ -460,47 +453,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
'[useSpecGeneration] Starting spec regeneration, generateFeatures:',
generateFeaturesOnRegenerate
);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
logger.error('[useSpecGeneration] Spec regeneration not available');
setIsRegenerating(false);
return;
}
const result = await api.specRegeneration.generate(
currentProject.path,
projectDefinition.trim(),
generateFeaturesOnRegenerate,
analyzeProjectOnRegenerate,
generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined
);
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start regeneration:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
regenerateSpecMutation.mutate(
{
projectDefinition: projectDefinition.trim(),
generateFeatures: generateFeaturesOnRegenerate,
analyzeProject: analyzeProjectOnRegenerate,
featureCount: generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined,
},
{
onError: (error) => {
const errorMsg = error.message;
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
},
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
);
}, [
currentProject,
projectDefinition,
generateFeaturesOnRegenerate,
analyzeProjectOnRegenerate,
featureCountOnRegenerate,
regenerateSpecMutation,
]);
const handleGenerateFeatures = useCallback(async () => {
@@ -513,36 +493,20 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
logsRef.current = '';
setLogs('');
logger.debug('[useSpecGeneration] Starting feature generation from existing spec');
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
logger.error('[useSpecGeneration] Spec regeneration not available');
setIsGeneratingFeatures(false);
return;
}
const result = await api.specRegeneration.generateFeatures(currentProject.path);
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start feature generation:', errorMsg);
generateFeaturesMutation.mutate(undefined, {
onError: (error) => {
const errorMsg = error.message;
logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
setIsGeneratingFeatures(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`;
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
setIsGeneratingFeatures(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
}, [currentProject]);
},
});
}, [currentProject, generateFeaturesMutation]);
return {
// Dialog state

View File

@@ -1,61 +1,53 @@
import { useEffect, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('SpecLoading');
import { getElectronAPI } from '@/lib/electron';
import { useSpecFile, useSpecRegenerationStatus } from '@/hooks/queries';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
export function useSpecLoading() {
const { currentProject, setAppSpec } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const queryClient = useQueryClient();
const [specExists, setSpecExists] = useState(true);
const [isGenerationRunning, setIsGenerationRunning] = useState(false);
const loadSpec = useCallback(async () => {
if (!currentProject) return;
// React Query hooks
const specFileQuery = useSpecFile(currentProject?.path);
const statusQuery = useSpecRegenerationStatus(currentProject?.path);
setIsLoading(true);
try {
const api = getElectronAPI();
// Check if spec generation is running before trying to load
// This prevents showing "No App Specification Found" during generation
if (api.specRegeneration) {
const status = await api.specRegeneration.status(currentProject.path);
if (status.success && status.isRunning) {
logger.debug('Spec generation is running for this project, skipping load');
setIsGenerationRunning(true);
setIsLoading(false);
return;
}
}
// Always reset when generation is not running (handles edge case where api.specRegeneration might not be available)
setIsGenerationRunning(false);
const result = await api.readFile(`${currentProject.path}/.automaker/app_spec.txt`);
if (result.success && result.content) {
setAppSpec(result.content);
setSpecExists(true);
} else {
// File doesn't exist
setAppSpec('');
setSpecExists(false);
}
} catch (error) {
logger.error('Failed to load spec:', error);
setSpecExists(false);
} finally {
setIsLoading(false);
}
}, [currentProject, setAppSpec]);
const isGenerationRunning = statusQuery.data?.isRunning ?? false;
// Update app store and specExists when spec file data changes
useEffect(() => {
loadSpec();
}, [loadSpec]);
if (specFileQuery.data && !isGenerationRunning) {
setAppSpec(specFileQuery.data.content);
setSpecExists(specFileQuery.data.exists);
}
}, [specFileQuery.data, setAppSpec, isGenerationRunning]);
// Manual reload function (invalidates cache)
const loadSpec = useCallback(async () => {
if (!currentProject?.path) return;
// First check if generation is running
await queryClient.invalidateQueries({
queryKey: queryKeys.specRegeneration.status(currentProject.path),
});
const statusData = queryClient.getQueryData<{ isRunning: boolean }>(
queryKeys.specRegeneration.status(currentProject.path)
);
if (statusData?.isRunning) {
return;
}
// Invalidate and refetch spec file
await queryClient.invalidateQueries({
queryKey: queryKeys.spec.file(currentProject.path),
});
}, [currentProject?.path, queryClient]);
return {
isLoading,
isLoading: specFileQuery.isLoading,
specExists,
setSpecExists,
isGenerationRunning,

View File

@@ -1,28 +1,20 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('SpecSave');
import { getElectronAPI } from '@/lib/electron';
import { useSaveSpec } from '@/hooks/mutations';
export function useSpecSave() {
const { currentProject, appSpec, setAppSpec } = useAppStore();
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// React Query mutation
const saveMutation = useSaveSpec(currentProject?.path ?? '');
const saveSpec = async () => {
if (!currentProject) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(`${currentProject.path}/.automaker/app_spec.txt`, appSpec);
setHasChanges(false);
} catch (error) {
logger.error('Failed to save spec:', error);
} finally {
setIsSaving(false);
}
saveMutation.mutate(appSpec, {
onSuccess: () => setHasChanges(false),
});
};
const handleChange = (value: string) => {
@@ -31,7 +23,7 @@ export function useSpecSave() {
};
return {
isSaving,
isSaving: saveMutation.isPending,
hasChanges,
setHasChanges,
saveSpec,

View File

@@ -1,36 +1,26 @@
import { useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
const logger = createLogger('BoardBackground');
import { useUpdateProjectSettings } from '@/hooks/mutations';
/**
* Hook for managing board background settings with automatic persistence to server
* Hook for managing board background settings with automatic persistence to server.
* Uses React Query mutation for server persistence with automatic error handling.
*/
export function useBoardBackgroundSettings() {
const store = useAppStore();
const httpClient = getHttpApiClient();
// Get the mutation without a fixed project path - we'll pass it with each call
const updateProjectSettings = useUpdateProjectSettings();
// Helper to persist settings to server
const persistSettings = useCallback(
async (projectPath: string, settingsToUpdate: Record<string, unknown>) => {
try {
const result = await httpClient.settings.updateProject(projectPath, {
boardBackground: settingsToUpdate,
});
if (!result.success) {
logger.error('Failed to persist settings:', result.error);
toast.error('Failed to save settings');
}
} catch (error) {
logger.error('Failed to persist settings:', error);
toast.error('Failed to save settings');
}
(projectPath: string, settingsToUpdate: Record<string, unknown>) => {
updateProjectSettings.mutate({
projectPath,
settings: { boardBackground: settingsToUpdate },
});
},
[httpClient]
[updateProjectSettings]
);
// Get current background settings for a project

View File

@@ -2,12 +2,12 @@
* Hook for fetching guided prompts from the backend API
*
* This hook provides the single source of truth for guided prompts,
* fetched from the backend /api/ideation/prompts endpoint.
* with caching via React Query.
*/
import { useState, useEffect, useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import type { IdeationPrompt, PromptCategory, IdeaCategory } from '@automaker/types';
import { getElectronAPI } from '@/lib/electron';
import { useIdeationPrompts } from '@/hooks/queries';
interface UseGuidedPromptsReturn {
prompts: IdeationPrompt[];
@@ -21,36 +21,10 @@ interface UseGuidedPromptsReturn {
}
export function useGuidedPrompts(): UseGuidedPromptsReturn {
const [prompts, setPrompts] = useState<IdeationPrompt[]>([]);
const [categories, setCategories] = useState<PromptCategory[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { data, isLoading, error, refetch } = useIdeationPrompts();
const fetchPrompts = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
const result = await api.ideation?.getPrompts();
if (result?.success) {
setPrompts(result.prompts || []);
setCategories(result.categories || []);
} else {
setError(result?.error || 'Failed to fetch prompts');
}
} catch (err) {
console.error('Failed to fetch guided prompts:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch prompts');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchPrompts();
}, [fetchPrompts]);
const prompts = data?.prompts ?? [];
const categories = data?.categories ?? [];
const getPromptsByCategory = useCallback(
(category: IdeaCategory): IdeationPrompt[] => {
@@ -73,12 +47,23 @@ export function useGuidedPrompts(): UseGuidedPromptsReturn {
[categories]
);
// Convert async refetch to match the expected interface
const handleRefetch = useCallback(async () => {
await refetch();
}, [refetch]);
// Convert error to string for backward compatibility
const errorMessage = useMemo(() => {
if (!error) return null;
return error instanceof Error ? error.message : String(error);
}, [error]);
return {
prompts,
categories,
isLoading,
error,
refetch: fetchPrompts,
error: errorMessage,
refetch: handleRefetch,
getPromptsByCategory,
getPromptById,
getCategoryById,

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useProjectSettings } from '@/hooks/queries';
/**
* Hook that loads project settings from the server when the current project changes.
* This ensures that settings like board backgrounds are properly restored when
* switching between projects or restarting the app.
*
* Uses React Query for data fetching with automatic caching.
*/
export function useProjectSettingsLoader() {
const currentProject = useAppStore((state) => state.currentProject);
@@ -24,93 +26,84 @@ export function useProjectSettingsLoader() {
(state) => state.setAutoDismissInitScriptIndicator
);
const loadingRef = useRef<string | null>(null);
const currentProjectRef = useRef<string | null>(null);
const appliedProjectRef = useRef<string | null>(null);
// Fetch project settings with React Query
const { data: settings } = useProjectSettings(currentProject?.path);
// Apply settings when data changes
useEffect(() => {
currentProjectRef.current = currentProject?.path ?? null;
if (!currentProject?.path) {
if (!currentProject?.path || !settings) {
return;
}
// Prevent loading the same project multiple times
if (loadingRef.current === currentProject.path) {
// Prevent applying the same settings multiple times
if (appliedProjectRef.current === currentProject.path) {
return;
}
loadingRef.current = currentProject.path;
const requestedProjectPath = currentProject.path;
appliedProjectRef.current = currentProject.path;
const projectPath = currentProject.path;
const loadProjectSettings = async () => {
try {
const httpClient = getHttpApiClient();
const result = await httpClient.settings.getProject(requestedProjectPath);
const bg = settings.boardBackground;
// Race condition protection: ignore stale results if project changed
if (currentProjectRef.current !== requestedProjectPath) {
return;
}
// Apply boardBackground if present
if (bg?.imagePath) {
setBoardBackground(projectPath, bg.imagePath);
}
if (result.success && result.settings) {
const bg = result.settings.boardBackground;
// Settings map for cleaner iteration
const settingsMap = {
cardOpacity: setCardOpacity,
columnOpacity: setColumnOpacity,
columnBorderEnabled: setColumnBorderEnabled,
cardGlassmorphism: setCardGlassmorphism,
cardBorderEnabled: setCardBorderEnabled,
cardBorderOpacity: setCardBorderOpacity,
hideScrollbar: setHideScrollbar,
} as const;
// Apply boardBackground if present
if (bg?.imagePath) {
setBoardBackground(requestedProjectPath, bg.imagePath);
}
// Settings map for cleaner iteration
const settingsMap = {
cardOpacity: setCardOpacity,
columnOpacity: setColumnOpacity,
columnBorderEnabled: setColumnBorderEnabled,
cardGlassmorphism: setCardGlassmorphism,
cardBorderEnabled: setCardBorderEnabled,
cardBorderOpacity: setCardBorderOpacity,
hideScrollbar: setHideScrollbar,
} as const;
// Apply all settings that are defined
for (const [key, setter] of Object.entries(settingsMap)) {
const value = bg?.[key as keyof typeof bg];
if (value !== undefined) {
(setter as (path: string, val: typeof value) => void)(requestedProjectPath, value);
}
}
// Apply worktreePanelVisible if present
if (result.settings.worktreePanelVisible !== undefined) {
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
}
// Apply showInitScriptIndicator if present
if (result.settings.showInitScriptIndicator !== undefined) {
setShowInitScriptIndicator(
requestedProjectPath,
result.settings.showInitScriptIndicator
);
}
// Apply defaultDeleteBranch if present
if (result.settings.defaultDeleteBranch !== undefined) {
setDefaultDeleteBranch(requestedProjectPath, result.settings.defaultDeleteBranch);
}
// Apply autoDismissInitScriptIndicator if present
if (result.settings.autoDismissInitScriptIndicator !== undefined) {
setAutoDismissInitScriptIndicator(
requestedProjectPath,
result.settings.autoDismissInitScriptIndicator
);
}
}
} catch (error) {
console.error('Failed to load project settings:', error);
// Don't show error toast - just log it
// Apply all settings that are defined
for (const [key, setter] of Object.entries(settingsMap)) {
const value = bg?.[key as keyof typeof bg];
if (value !== undefined) {
(setter as (path: string, val: typeof value) => void)(projectPath, value);
}
};
}
loadProjectSettings();
}, [currentProject?.path]);
// Apply worktreePanelVisible if present
if (settings.worktreePanelVisible !== undefined) {
setWorktreePanelVisible(projectPath, settings.worktreePanelVisible);
}
// Apply showInitScriptIndicator if present
if (settings.showInitScriptIndicator !== undefined) {
setShowInitScriptIndicator(projectPath, settings.showInitScriptIndicator);
}
// Apply defaultDeleteBranchWithWorktree if present
if (settings.defaultDeleteBranchWithWorktree !== undefined) {
setDefaultDeleteBranch(projectPath, settings.defaultDeleteBranchWithWorktree);
}
// Apply autoDismissInitScriptIndicator if present
if (settings.autoDismissInitScriptIndicator !== undefined) {
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
}
}, [
currentProject?.path,
settings,
setBoardBackground,
setCardOpacity,
setColumnOpacity,
setColumnBorderEnabled,
setCardGlassmorphism,
setCardBorderEnabled,
setCardBorderOpacity,
setHideScrollbar,
setWorktreePanelVisible,
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
]);
}