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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -9,7 +8,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react'; import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
import { getHttpApiClient } from '@/lib/http-api-client'; import { useWorkspaceDirectories } from '@/hooks/queries';
interface WorkspaceDirectory { interface WorkspaceDirectory {
name: string; name: string;
@@ -23,41 +22,15 @@ interface WorkspacePickerModalProps {
} }
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) { export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
const [isLoading, setIsLoading] = useState(false); // React Query hook - only fetch when modal is open
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]); const { data: directories = [], isLoading, error, refetch } = useWorkspaceDirectories(open);
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]);
const handleSelect = (dir: WorkspaceDirectory) => { const handleSelect = (dir: WorkspaceDirectory) => {
onSelect(dir.path, dir.name); onSelect(dir.path, dir.name);
}; };
const errorMessage = error instanceof Error ? error.message : null;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-card border-border max-w-lg max-h-[80vh] flex flex-col"> <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> </div>
)} )}
{error && !isLoading && ( {errorMessage && !isLoading && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4"> <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"> <div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-destructive" /> <AlertCircle className="w-6 h-6 text-destructive" />
</div> </div>
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{errorMessage}</p>
<Button variant="secondary" size="sm" onClick={loadDirectories} className="mt-2"> <Button variant="secondary" size="sm" onClick={() => refetch()} className="mt-2">
Try Again Try Again
</Button> </Button>
</div> </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="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"> <div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
<Folder className="w-6 h-6 text-muted-foreground" /> <Folder className="w-6 h-6 text-muted-foreground" />
@@ -102,7 +75,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
</div> </div>
)} )}
{!isLoading && !error && directories.length > 0 && ( {!isLoading && !errorMessage && directories.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{directories.map((dir) => ( {directories.map((dir) => (
<button <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 { createLogger } from '@automaker/utils/logger';
import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const logger = createLogger('SessionManager'); const logger = createLogger('SessionManager');
@@ -22,6 +23,8 @@ import { cn } from '@/lib/utils';
import type { SessionListItem } from '@/types/electron'; import type { SessionListItem } from '@/types/electron';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron'; 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 { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog'; import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
@@ -102,7 +105,7 @@ export function SessionManager({
onQuickCreateRef, onQuickCreateRef,
}: SessionManagerProps) { }: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
const [sessions, setSessions] = useState<SessionListItem[]>([]); const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active'); const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
const [editingSessionId, setEditingSessionId] = useState<string | null>(null); const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingName, setEditingName] = useState(''); const [editingName, setEditingName] = useState('');
@@ -113,8 +116,11 @@ export function SessionManager({
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null); const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false); 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 // Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => { const checkRunningSessions = useCallback(async (sessionList: SessionListItem[]) => {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.agent) return; if (!api?.agent) return;
@@ -134,26 +140,25 @@ export function SessionManager({
} }
setRunningSessions(runningIds); 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) // Periodically check running state for sessions (useful for detecting when agents finish)
useEffect(() => { useEffect(() => {
// Only poll if there are running sessions // Only poll if there are running sessions
@@ -166,7 +171,7 @@ export function SessionManager({
}, 3000); // Check every 3 seconds }, 3000); // Check every 3 seconds
return () => clearInterval(interval); return () => clearInterval(interval);
}, [sessions, runningSessions.size, isCurrentSessionThinking]); }, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
// Create new session with random name // Create new session with random name
const handleCreateSession = async () => { const handleCreateSession = async () => {
@@ -180,7 +185,7 @@ export function SessionManager({
if (result.success && result.session?.id) { if (result.success && result.session?.id) {
setNewSessionName(''); setNewSessionName('');
setIsCreating(false); setIsCreating(false);
await loadSessions(); await invalidateSessions();
onSelectSession(result.session.id); onSelectSession(result.session.id);
} }
}; };
@@ -195,7 +200,7 @@ export function SessionManager({
const result = await api.sessions.create(sessionName, projectPath, projectPath); const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) { if (result.success && result.session?.id) {
await loadSessions(); await invalidateSessions();
onSelectSession(result.session.id); onSelectSession(result.session.id);
} }
}; };
@@ -222,7 +227,7 @@ export function SessionManager({
if (result.success) { if (result.success) {
setEditingSessionId(null); setEditingSessionId(null);
setEditingName(''); setEditingName('');
await loadSessions(); await invalidateSessions();
} }
}; };
@@ -241,7 +246,7 @@ export function SessionManager({
if (currentSessionId === sessionId) { if (currentSessionId === sessionId) {
onSelectSession(null); onSelectSession(null);
} }
await loadSessions(); await invalidateSessions();
} else { } else {
logger.error('[SessionManager] Archive failed:', result.error); logger.error('[SessionManager] Archive failed:', result.error);
} }
@@ -261,7 +266,7 @@ export function SessionManager({
try { try {
const result = await api.sessions.unarchive(sessionId); const result = await api.sessions.unarchive(sessionId);
if (result.success) { if (result.success) {
await loadSessions(); await invalidateSessions();
} else { } else {
logger.error('[SessionManager] Unarchive failed:', result.error); logger.error('[SessionManager] Unarchive failed:', result.error);
} }
@@ -283,7 +288,7 @@ export function SessionManager({
const result = await api.sessions.delete(sessionId); const result = await api.sessions.delete(sessionId);
if (result.success) { if (result.success) {
await loadSessions(); await invalidateSessions();
if (currentSessionId === sessionId) { if (currentSessionId === sessionId) {
// Switch to another session or create a new one // Switch to another session or create a new one
const activeSessionsList = sessions.filter((s) => !s.isArchived); const activeSessionsList = sessions.filter((s) => !s.isArchived);
@@ -305,7 +310,7 @@ export function SessionManager({
await api.sessions.delete(session.id); await api.sessions.delete(session.id);
} }
await loadSessions(); await invalidateSessions();
setIsDeleteAllArchivedDialogOpen(false); setIsDeleteAllArchivedDialogOpen(false);
}; };

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useMemo } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
File, File,
@@ -15,6 +14,7 @@ import {
AlertCircle, AlertCircle,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from './button'; import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import type { FileStatus } from '@/types/electron'; import type { FileStatus } from '@/types/electron';
interface GitDiffPanelProps { interface GitDiffPanelProps {
@@ -350,56 +350,44 @@ export function GitDiffPanel({
useWorktrees = false, useWorktrees = false,
}: GitDiffPanelProps) { }: GitDiffPanelProps) {
const [isExpanded, setIsExpanded] = useState(!compact); 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 [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const loadDiffs = useCallback(async () => { // Use worktree diffs hook when worktrees are enabled and panel is expanded
setIsLoading(true); // Pass undefined for featureId when not using worktrees to disable the query
setError(null); const {
try { data: worktreeDiffsData,
const api = getElectronAPI(); 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 // Use git diffs hook when worktrees are disabled and panel is expanded
if (useWorktrees) { const {
if (!api?.worktree?.getDiffs) { data: gitDiffsData,
throw new Error('Worktree API not available'); isLoading: isLoadingGit,
} error: gitError,
const result = await api.worktree.getDiffs(projectPath, featureId); refetch: refetchGit,
if (result.success) { } = useGitDiffs(projectPath, !useWorktrees && isExpanded);
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]);
// Load diffs when expanded // Select the appropriate data based on useWorktrees prop
useEffect(() => { const diffsData = useWorktrees ? worktreeDiffsData : gitDiffsData;
if (isExpanded) { const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
loadDiffs(); const queryError = useWorktrees ? worktreeError : gitError;
}
}, [isExpanded, loadDiffs]); // 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]); 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 { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { useIdeationStore } from '@/store/ideation-store'; import { useIdeationStore } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron'; import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import type { IdeaCategory, IdeationPrompt } from '@automaker/types'; 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 [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set()); const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
const navigate = useNavigate(); const navigate = useNavigate();
// React Query mutation
const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
const { const {
getPromptsByCategory, getPromptsByCategory,
isLoading: isLoadingPrompts, isLoading: isLoadingPrompts,
@@ -56,7 +59,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
return; return;
} }
if (loadingPromptId || generatingPromptIds.has(prompt.id)) return; if (loadingPromptId || generateMutation.isPending || generatingPromptIds.has(prompt.id)) return;
setLoadingPromptId(prompt.id); setLoadingPromptId(prompt.id);
@@ -68,17 +71,12 @@ export function PromptList({ category, onBack }: PromptListProps) {
toast.info(`Generating ideas for "${prompt.title}"...`); toast.info(`Generating ideas for "${prompt.title}"...`);
setMode('dashboard'); setMode('dashboard');
try { generateMutation.mutate(
const api = getElectronAPI(); { promptId: prompt.id, category },
const result = await api.ideation?.generateSuggestions( {
currentProject.path, onSuccess: (data) => {
prompt.id, updateJobStatus(jobId, 'ready', data.suggestions);
category toast.success(`Generated ${data.suggestions.length} ideas for "${prompt.title}"`, {
);
if (result?.success && result.suggestions) {
updateJobStatus(jobId, 'ready', result.suggestions);
toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, {
duration: 10000, duration: 10000,
action: { action: {
label: 'View Ideas', label: 'View Ideas',
@@ -88,22 +86,16 @@ export function PromptList({ category, onBack }: PromptListProps) {
}, },
}, },
}); });
} else {
updateJobStatus(
jobId,
'error',
undefined,
result?.error || 'Failed to generate suggestions'
);
toast.error(result?.error || 'Failed to generate suggestions');
}
} 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); setLoadingPromptId(null);
},
onError: (error) => {
console.error('Failed to generate suggestions:', error);
updateJobStatus(jobId, 'error', undefined, error.message);
toast.error(error.message);
setLoadingPromptId(null);
},
} }
);
}; };
return ( return (

View File

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

View File

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

View File

@@ -1,28 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSaveSpec } from '@/hooks/mutations';
const logger = createLogger('SpecSave');
import { getElectronAPI } from '@/lib/electron';
export function useSpecSave() { export function useSpecSave() {
const { currentProject, appSpec, setAppSpec } = useAppStore(); const { currentProject, appSpec, setAppSpec } = useAppStore();
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
// React Query mutation
const saveMutation = useSaveSpec(currentProject?.path ?? '');
const saveSpec = async () => { const saveSpec = async () => {
if (!currentProject) return; if (!currentProject) return;
setIsSaving(true); saveMutation.mutate(appSpec, {
try { onSuccess: () => setHasChanges(false),
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);
}
}; };
const handleChange = (value: string) => { const handleChange = (value: string) => {
@@ -31,7 +23,7 @@ export function useSpecSave() {
}; };
return { return {
isSaving, isSaving: saveMutation.isPending,
hasChanges, hasChanges,
setHasChanges, setHasChanges,
saveSpec, saveSpec,

View File

@@ -1,36 +1,26 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client'; import { useUpdateProjectSettings } from '@/hooks/mutations';
import { toast } from 'sonner';
const logger = createLogger('BoardBackground');
/** /**
* 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() { export function useBoardBackgroundSettings() {
const store = useAppStore(); 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 // Helper to persist settings to server
const persistSettings = useCallback( const persistSettings = useCallback(
async (projectPath: string, settingsToUpdate: Record<string, unknown>) => { (projectPath: string, settingsToUpdate: Record<string, unknown>) => {
try { updateProjectSettings.mutate({
const result = await httpClient.settings.updateProject(projectPath, { projectPath,
boardBackground: settingsToUpdate, settings: { 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');
}
}, },
[httpClient] [updateProjectSettings]
); );
// Get current background settings for a project // Get current background settings for a project

View File

@@ -2,12 +2,12 @@
* Hook for fetching guided prompts from the backend API * Hook for fetching guided prompts from the backend API
* *
* This hook provides the single source of truth for guided prompts, * 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 type { IdeationPrompt, PromptCategory, IdeaCategory } from '@automaker/types';
import { getElectronAPI } from '@/lib/electron'; import { useIdeationPrompts } from '@/hooks/queries';
interface UseGuidedPromptsReturn { interface UseGuidedPromptsReturn {
prompts: IdeationPrompt[]; prompts: IdeationPrompt[];
@@ -21,36 +21,10 @@ interface UseGuidedPromptsReturn {
} }
export function useGuidedPrompts(): UseGuidedPromptsReturn { export function useGuidedPrompts(): UseGuidedPromptsReturn {
const [prompts, setPrompts] = useState<IdeationPrompt[]>([]); const { data, isLoading, error, refetch } = useIdeationPrompts();
const [categories, setCategories] = useState<PromptCategory[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPrompts = useCallback(async () => { const prompts = data?.prompts ?? [];
setIsLoading(true); const categories = data?.categories ?? [];
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 getPromptsByCategory = useCallback( const getPromptsByCategory = useCallback(
(category: IdeaCategory): IdeationPrompt[] => { (category: IdeaCategory): IdeationPrompt[] => {
@@ -73,12 +47,23 @@ export function useGuidedPrompts(): UseGuidedPromptsReturn {
[categories] [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 { return {
prompts, prompts,
categories, categories,
isLoading, isLoading,
error, error: errorMessage,
refetch: fetchPrompts, refetch: handleRefetch,
getPromptsByCategory, getPromptsByCategory,
getPromptById, getPromptById,
getCategoryById, getCategoryById,

View File

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