Merge pull request #499 from AutoMaker-Org/feat/react-query

feat(ui): migrate to React Query for data fetching
This commit is contained in:
Dhanush Santosh
2026-01-20 20:21:32 +05:30
committed by GitHub
99 changed files with 6551 additions and 2786 deletions

View File

@@ -2,6 +2,14 @@
- Setting the default model does not seem like it works.
# Performance (completed)
- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering)
- [x] Render containment on heavy scroll regions (kanban columns, chat history)
- [x] Reduce blur/shadow effects when lists get large
- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect)
- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections)
# UX
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff

View File

@@ -80,7 +80,8 @@
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "1.2.8",
"@tanstack/react-query": "5.90.12",
"@tanstack/react-query": "^5.90.17",
"@tanstack/react-query-devtools": "^5.91.2",
"@tanstack/react-router": "1.141.6",
"@uiw/react-codemirror": "4.25.4",
"@xterm/addon-fit": "0.10.0",

View File

@@ -1,115 +1,40 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
/**
* Claude Usage Popover
*
* Displays Claude API usage statistics using React Query for data fetching.
*/
import { useState, useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
AUTH_ERROR: 'AUTH_ERROR',
TRUST_PROMPT: 'TRUST_PROMPT',
UNKNOWN: 'UNKNOWN',
} as const;
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
type UsageError = {
code: ErrorCode;
message: string;
};
// Fixed refresh interval (45 seconds)
const REFRESH_INTERVAL_SECONDS = 45;
import { useClaudeUsage } from '@/hooks/queries';
export function ClaudeUsagePopover() {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<UsageError | null>(null);
// Check if CLI is verified/authenticated
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
// Use React Query for usage data
const {
data: claudeUsage,
isLoading,
isFetching,
error,
dataUpdatedAt,
refetch,
} = useClaudeUsage(isCliVerified);
// Check if data is stale (older than 2 minutes)
const isStale = useMemo(() => {
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
}, [claudeUsageLastUpdated]);
const fetchUsage = useCallback(
async (isAutoRefresh = false) => {
if (!isAutoRefresh) setLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.claude) {
setError({
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
message: 'Claude API bridge not available',
});
return;
}
const data = await api.claude.getUsage();
if ('error' in data) {
// Detect trust prompt error
const isTrustPrompt =
data.error === 'Trust prompt pending' ||
(data.message && data.message.includes('folder permission'));
setError({
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
return;
}
setClaudeUsage(data);
} catch (err) {
setError({
code: ERROR_CODES.UNKNOWN,
message: err instanceof Error ? err.message : 'Failed to fetch usage',
});
} finally {
if (!isAutoRefresh) setLoading(false);
}
},
[setClaudeUsage]
);
// Auto-fetch on mount if data is stale (only if CLI is verified)
useEffect(() => {
if (isStale && isCliVerified) {
fetchUsage(true);
}
}, [isStale, isCliVerified, fetchUsage]);
useEffect(() => {
// Skip if CLI is not verified
if (!isCliVerified) return;
// Initial fetch when opened
if (open) {
if (!claudeUsage || isStale) {
fetchUsage();
}
}
// Auto-refresh interval (only when open)
let intervalId: NodeJS.Timeout | null = null;
if (open) {
intervalId = setInterval(() => {
fetchUsage(true);
}, REFRESH_INTERVAL_SECONDS * 1000);
}
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
}, [dataUpdatedAt]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -144,7 +69,6 @@ export function ClaudeUsagePopover() {
isPrimary?: boolean;
stale?: boolean;
}) => {
// Check if percentage is valid (not NaN, not undefined, is a finite number)
const isValidPercentage =
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
const safePercentage = isValidPercentage ? percentage : 0;
@@ -245,10 +169,10 @@ export function ClaudeUsagePopover() {
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', loading && 'opacity-80')}
onClick={() => !loading && fetchUsage(false)}
className={cn('h-6 w-6', isFetching && 'opacity-80')}
onClick={() => !isFetching && refetch()}
>
<RefreshCw className="w-3.5 h-3.5" />
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</Button>
)}
</div>
@@ -259,26 +183,16 @@ export function ClaudeUsagePopover() {
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<div className="space-y-1 flex flex-col items-center">
<p className="text-sm font-medium">{error.message}</p>
<p className="text-sm font-medium">
{error instanceof Error ? error.message : 'Failed to fetch usage'}
</p>
<p className="text-xs text-muted-foreground">
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : error.code === ERROR_CODES.TRUST_PROMPT ? (
<>
Run <code className="font-mono bg-muted px-1 rounded">claude</code> in your
terminal and approve access to continue
</>
) : (
<>
Make sure Claude CLI is installed and authenticated via{' '}
<code className="font-mono bg-muted px-1 rounded">claude login</code>
</>
)}
Make sure Claude CLI is installed and authenticated via{' '}
<code className="font-mono bg-muted px-1 rounded">claude login</code>
</p>
</div>
</div>
) : !claudeUsage ? (
// Loading state
) : isLoading || !claudeUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>

View File

@@ -1,12 +1,11 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useCodexUsage } from '@/hooks/queries';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -23,9 +22,6 @@ type UsageError = {
message: string;
};
// Fixed refresh interval (45 seconds)
const REFRESH_INTERVAL_SECONDS = 45;
// Helper to format reset time
function formatResetTime(unixTimestamp: number): string {
const date = new Date(unixTimestamp * 1000);
@@ -63,95 +59,39 @@ function getWindowLabel(durationMins: number): { title: string; subtitle: string
}
export function CodexUsagePopover() {
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<UsageError | null>(null);
// Check if Codex is authenticated
const isCodexAuthenticated = codexAuthStatus?.authenticated;
// Use React Query for data fetching with automatic polling
const {
data: codexUsage,
isLoading,
isFetching,
error: queryError,
dataUpdatedAt,
refetch,
} = useCodexUsage(isCodexAuthenticated);
// Check if data is stale (older than 2 minutes)
const isStale = useMemo(() => {
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
}, [codexUsageLastUpdated]);
return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
}, [dataUpdatedAt]);
const fetchUsage = useCallback(
async (isAutoRefresh = false) => {
if (!isAutoRefresh) setLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.codex) {
setError({
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
message: 'Codex API bridge not available',
});
return;
}
const data = await api.codex.getUsage();
if ('error' in data) {
// Check if it's the "not available" error
if (
data.message?.includes('not available') ||
data.message?.includes('does not provide')
) {
setError({
code: ERROR_CODES.NOT_AVAILABLE,
message: data.message || data.error,
});
} else {
setError({
code: ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
}
return;
}
setCodexUsage(data);
} catch (err) {
setError({
code: ERROR_CODES.UNKNOWN,
message: err instanceof Error ? err.message : 'Failed to fetch usage',
});
} finally {
if (!isAutoRefresh) setLoading(false);
}
},
[setCodexUsage]
);
// Auto-fetch on mount if data is stale (only if authenticated)
useEffect(() => {
if (isStale && isCodexAuthenticated) {
fetchUsage(true);
// Convert query error to UsageError format for backward compatibility
const error = useMemo((): UsageError | null => {
if (!queryError) return null;
const message = queryError instanceof Error ? queryError.message : String(queryError);
if (message.includes('not available') || message.includes('does not provide')) {
return { code: ERROR_CODES.NOT_AVAILABLE, message };
}
}, [isStale, isCodexAuthenticated, fetchUsage]);
useEffect(() => {
// Skip if not authenticated
if (!isCodexAuthenticated) return;
// Initial fetch when opened
if (open) {
if (!codexUsage || isStale) {
fetchUsage();
}
if (message.includes('bridge') || message.includes('API')) {
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
}
// Auto-refresh interval (only when open)
let intervalId: NodeJS.Timeout | null = null;
if (open) {
intervalId = setInterval(() => {
fetchUsage(true);
}, REFRESH_INTERVAL_SECONDS * 1000);
}
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [open, codexUsage, isStale, isCodexAuthenticated, fetchUsage]);
return { code: ERROR_CODES.AUTH_ERROR, message };
}, [queryError]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -289,10 +229,10 @@ export function CodexUsagePopover() {
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', loading && 'opacity-80')}
onClick={() => !loading && fetchUsage(false)}
className={cn('h-6 w-6', isFetching && 'opacity-80')}
onClick={() => !isFetching && refetch()}
>
<RefreshCw className="w-3.5 h-3.5" />
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</Button>
)}
</div>

View File

@@ -1,4 +1,3 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -10,7 +9,7 @@ import {
import { Button } from '@/components/ui/button';
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useWorkspaceDirectories } from '@/hooks/queries';
interface WorkspaceDirectory {
name: string;
@@ -24,41 +23,15 @@ interface WorkspacePickerModalProps {
}
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [directories, setDirectories] = useState<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">
@@ -80,19 +53,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" />
@@ -103,7 +76,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, useRef } 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,14 @@ 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);
// Ref to track if we've done the initial running sessions check
const hasCheckedInitialRef = useRef(false);
// 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 +143,26 @@ 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 (runs only once when sessions first load)
useEffect(() => {
if (sessions.length > 0 && !hasCheckedInitialRef.current) {
hasCheckedInitialRef.current = true;
checkRunningSessions(sessions);
}
}, [sessions, checkRunningSessions]);
// Periodically check running state for sessions (useful for detecting when agents finish)
useEffect(() => {
// Only poll if there are running sessions
@@ -166,7 +175,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 +189,7 @@ export function SessionManager({
if (result.success && result.session?.id) {
setNewSessionName('');
setIsCreating(false);
await loadSessions();
await invalidateSessions();
onSelectSession(result.session.id);
}
};
@@ -195,7 +204,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 +231,7 @@ export function SessionManager({
if (result.success) {
setEditingSessionId(null);
setEditingName('');
await loadSessions();
await invalidateSessions();
}
};
@@ -241,7 +250,7 @@ export function SessionManager({
if (currentSessionId === sessionId) {
onSelectSession(null);
}
await loadSessions();
await invalidateSessions();
} else {
logger.error('[SessionManager] Archive failed:', result.error);
}
@@ -261,7 +270,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 +292,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 +314,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 {
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
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

@@ -0,0 +1,18 @@
/**
* Skeleton Components
*
* Loading placeholder components for content that's being fetched.
*/
import { cn } from '@/lib/utils';
interface SkeletonPulseProps {
className?: string;
}
/**
* Pulsing skeleton placeholder for loading states
*/
export function SkeletonPulse({ className }: SkeletonPulseProps) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}

View File

@@ -1,14 +1,13 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -61,22 +60,63 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s
}
export function UsagePopover() {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude');
const [claudeLoading, setClaudeLoading] = useState(false);
const [codexLoading, setCodexLoading] = useState(false);
const [claudeError, setClaudeError] = useState<UsageError | null>(null);
const [codexError, setCodexError] = useState<UsageError | null>(null);
// Check authentication status
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
const isCodexAuthenticated = codexAuthStatus?.authenticated;
// Use React Query hooks for usage data
// Only enable polling when popover is open AND the tab is active
const {
data: claudeUsage,
isLoading: claudeLoading,
error: claudeQueryError,
dataUpdatedAt: claudeUsageLastUpdated,
refetch: refetchClaude,
} = useClaudeUsage(open && activeTab === 'claude' && isClaudeAuthenticated);
const {
data: codexUsage,
isLoading: codexLoading,
error: codexQueryError,
dataUpdatedAt: codexUsageLastUpdated,
refetch: refetchCodex,
} = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated);
// Parse errors into structured format
const claudeError = useMemo((): UsageError | null => {
if (!claudeQueryError) return null;
const message =
claudeQueryError instanceof Error ? claudeQueryError.message : String(claudeQueryError);
// Detect trust prompt error
const isTrustPrompt = message.includes('Trust prompt') || message.includes('folder permission');
if (isTrustPrompt) {
return { code: ERROR_CODES.TRUST_PROMPT, message };
}
if (message.includes('API bridge')) {
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
}
return { code: ERROR_CODES.AUTH_ERROR, message };
}, [claudeQueryError]);
const codexError = useMemo((): UsageError | null => {
if (!codexQueryError) return null;
const message =
codexQueryError instanceof Error ? codexQueryError.message : String(codexQueryError);
if (message.includes('not available') || message.includes('does not provide')) {
return { code: ERROR_CODES.NOT_AVAILABLE, message };
}
if (message.includes('API bridge')) {
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
}
return { code: ERROR_CODES.AUTH_ERROR, message };
}, [codexQueryError]);
// Determine which tab to show by default
useEffect(() => {
if (isClaudeAuthenticated) {
@@ -95,137 +135,9 @@ export function UsagePopover() {
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
}, [codexUsageLastUpdated]);
const fetchClaudeUsage = useCallback(
async (isAutoRefresh = false) => {
if (!isAutoRefresh) setClaudeLoading(true);
setClaudeError(null);
try {
const api = getElectronAPI();
if (!api.claude) {
setClaudeError({
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
message: 'Claude API bridge not available',
});
return;
}
const data = await api.claude.getUsage();
if ('error' in data) {
// Detect trust prompt error
const isTrustPrompt =
data.error === 'Trust prompt pending' ||
(data.message && data.message.includes('folder permission'));
setClaudeError({
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
return;
}
setClaudeUsage(data);
} catch (err) {
setClaudeError({
code: ERROR_CODES.UNKNOWN,
message: err instanceof Error ? err.message : 'Failed to fetch usage',
});
} finally {
if (!isAutoRefresh) setClaudeLoading(false);
}
},
[setClaudeUsage]
);
const fetchCodexUsage = useCallback(
async (isAutoRefresh = false) => {
if (!isAutoRefresh) setCodexLoading(true);
setCodexError(null);
try {
const api = getElectronAPI();
if (!api.codex) {
setCodexError({
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
message: 'Codex API bridge not available',
});
return;
}
const data = await api.codex.getUsage();
if ('error' in data) {
if (
data.message?.includes('not available') ||
data.message?.includes('does not provide')
) {
setCodexError({
code: ERROR_CODES.NOT_AVAILABLE,
message: data.message || data.error,
});
} else {
setCodexError({
code: ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
}
return;
}
setCodexUsage(data);
} catch (err) {
setCodexError({
code: ERROR_CODES.UNKNOWN,
message: err instanceof Error ? err.message : 'Failed to fetch usage',
});
} finally {
if (!isAutoRefresh) setCodexLoading(false);
}
},
[setCodexUsage]
);
// Auto-fetch on mount if data is stale
useEffect(() => {
if (isClaudeStale && isClaudeAuthenticated) {
fetchClaudeUsage(true);
}
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
useEffect(() => {
if (isCodexStale && isCodexAuthenticated) {
fetchCodexUsage(true);
}
}, [isCodexStale, isCodexAuthenticated, fetchCodexUsage]);
// Auto-refresh when popover is open
useEffect(() => {
if (!open) return;
// Fetch based on active tab
if (activeTab === 'claude' && isClaudeAuthenticated) {
if (!claudeUsage || isClaudeStale) {
fetchClaudeUsage();
}
const intervalId = setInterval(() => {
fetchClaudeUsage(true);
}, REFRESH_INTERVAL_SECONDS * 1000);
return () => clearInterval(intervalId);
}
if (activeTab === 'codex' && isCodexAuthenticated) {
if (!codexUsage || isCodexStale) {
fetchCodexUsage();
}
const intervalId = setInterval(() => {
fetchCodexUsage(true);
}, REFRESH_INTERVAL_SECONDS * 1000);
return () => clearInterval(intervalId);
}
}, [
open,
activeTab,
claudeUsage,
isClaudeStale,
isClaudeAuthenticated,
codexUsage,
isCodexStale,
isCodexAuthenticated,
fetchClaudeUsage,
fetchCodexUsage,
]);
// Refetch functions for manual refresh
const fetchClaudeUsage = () => refetchClaude();
const fetchCodexUsage = () => refetchCodex();
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -417,7 +329,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
onClick={() => !claudeLoading && fetchClaudeUsage(false)}
onClick={() => !claudeLoading && fetchClaudeUsage()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
@@ -524,7 +436,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
onClick={() => !codexLoading && fetchCodexUsage(false)}
onClick={() => !codexLoading && fetchCodexUsage()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>

View File

@@ -1,7 +1,9 @@
import { useCallback, useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
@@ -72,6 +74,7 @@ export function AnalysisView() {
const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false);
const [featureListGenerated, setFeatureListGenerated] = useState(false);
const [featureListError, setFeatureListError] = useState<string | null>(null);
const queryClient = useQueryClient();
// Recursively scan directory
const scanDirectory = useCallback(
@@ -647,6 +650,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
} as any);
}
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
setFeatureListGenerated(true);
} catch (error) {
logger.error('Failed to generate feature list:', error);
@@ -656,7 +664,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
} finally {
setIsGeneratingFeatureList(false);
}
}, [currentProject, projectAnalysis]);
}, [currentProject, projectAnalysis, queryClient]);
// Toggle folder expansion
const toggleFolder = (path: string) => {

View File

@@ -35,6 +35,7 @@ import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { Spinner } from '@/components/ui/spinner';
import { useShallow } from 'zustand/react/shallow';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
@@ -79,6 +80,10 @@ import { SelectionActionBar, ListView } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
import { usePipelineConfig } from '@/hooks/queries';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -108,9 +113,37 @@ export function BoardView() {
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
} = useAppStore();
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
} = useAppStore(
useShallow((state) => ({
currentProject: state.currentProject,
maxConcurrency: state.maxConcurrency,
setMaxConcurrency: state.setMaxConcurrency,
defaultSkipTests: state.defaultSkipTests,
specCreatingForProject: state.specCreatingForProject,
setSpecCreatingForProject: state.setSpecCreatingForProject,
pendingPlanApproval: state.pendingPlanApproval,
setPendingPlanApproval: state.setPendingPlanApproval,
updateFeature: state.updateFeature,
getCurrentWorktree: state.getCurrentWorktree,
setCurrentWorktree: state.setCurrentWorktree,
getWorktrees: state.getWorktrees,
setWorktrees: state.setWorktrees,
useWorktrees: state.useWorktrees,
enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch,
setPipelineConfig: state.setPipelineConfig,
}))
);
// Fetch pipeline config via React Query
const { data: pipelineConfig } = usePipelineConfig(currentProject?.path);
const queryClient = useQueryClient();
// Subscribe to auto mode events for React Query cache invalidation
useAutoModeQueryInvalidation(currentProject?.path);
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
@@ -953,9 +986,7 @@ export function BoardView() {
});
// Build columnFeaturesMap for ListView
const pipelineConfig = currentProject?.path
? pipelineConfigByProject[currentProject.path] || null
: null;
// pipelineConfig is now from usePipelineConfig React Query hook at the top
const columnFeaturesMap = useMemo(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const map: Record<string, typeof hookFeatures> = {};
@@ -1441,6 +1472,11 @@ export function BoardView() {
if (!result.success) {
throw new Error(result.error || 'Failed to save pipeline config');
}
// Invalidate React Query cache to refetch updated config
queryClient.invalidateQueries({
queryKey: queryKeys.pipeline.config(currentProject.path),
});
// Also update Zustand for backward compatibility
setPipelineConfig(currentProject.path, config);
}}
/>

View File

@@ -1,5 +1,4 @@
// @ts-nocheck
import { useEffect, useState, useMemo } from 'react';
import { memo, useEffect, useState, useMemo } from 'react';
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
@@ -16,6 +15,7 @@ import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
import { useFeature, useAgentOutput } from '@/hooks/queries';
/**
* Formats thinking level for compact display
@@ -50,30 +50,62 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
interface AgentInfoPanelProps {
feature: Feature;
projectPath: string;
contextContent?: string;
summary?: string;
isCurrentAutoTask?: boolean;
}
export function AgentInfoPanel({
export const AgentInfoPanel = memo(function AgentInfoPanel({
feature,
projectPath,
contextContent,
summary,
isCurrentAutoTask,
}: AgentInfoPanelProps) {
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track real-time task status updates from WebSocket events
const [taskStatusMap, setTaskStatusMap] = useState<
Map<string, 'pending' | 'in_progress' | 'completed'>
>(new Map());
// Fresh planSpec data fetched from API (store data is stale for task progress)
const [freshPlanSpec, setFreshPlanSpec] = useState<{
tasks?: ParsedTask[];
tasksCompleted?: number;
currentTaskId?: string;
} | null>(null);
// Determine if we should poll for updates
const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
const shouldFetchData = feature.status !== 'backlog';
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
const { data: freshFeature } = useFeature(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent,
pollingInterval: shouldPoll ? 3000 : false,
});
// Fetch agent output for parsing
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent,
pollingInterval: shouldPoll ? 3000 : false,
});
// Parse agent output into agentInfo
const agentInfo = useMemo(() => {
if (contextContent) {
return parseAgentContext(contextContent);
}
if (agentOutputContent) {
return parseAgentContext(agentOutputContent);
}
return null;
}, [contextContent, agentOutputContent]);
// Fresh planSpec data from API (more accurate than store data for task progress)
const freshPlanSpec = useMemo(() => {
if (!freshFeature?.planSpec) return null;
return {
tasks: freshFeature.planSpec.tasks,
tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
currentTaskId: freshFeature.planSpec.currentTaskId,
};
}, [freshFeature?.planSpec]);
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
@@ -125,73 +157,6 @@ export function AgentInfoPanel({
taskStatusMap,
]);
useEffect(() => {
const loadContext = async () => {
if (contextContent) {
const info = parseAgentContext(contextContent);
setAgentInfo(info);
return;
}
if (feature.status === 'backlog') {
setAgentInfo(null);
setFreshPlanSpec(null);
return;
}
try {
const api = getElectronAPI();
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
if (api.features) {
// Fetch fresh feature data to get up-to-date planSpec (store data is stale)
try {
const featureResult = await api.features.get(currentProject.path, feature.id);
const freshFeature: any = (featureResult as any).feature;
if (featureResult.success && freshFeature?.planSpec) {
setFreshPlanSpec({
tasks: freshFeature.planSpec.tasks,
tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
currentTaskId: freshFeature.planSpec.currentTaskId,
});
}
} catch {
// Ignore errors fetching fresh planSpec
}
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
} else {
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
console.debug('[KanbanCard] No context file for feature:', feature.id);
}
};
loadContext();
// Poll for updates when feature is in_progress (not just isCurrentAutoTask)
// This ensures planSpec progress stays in sync
if (isCurrentAutoTask || feature.status === 'in_progress') {
const interval = setInterval(loadContext, 3000);
return () => {
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Listen to WebSocket events for real-time task status updates
// This ensures the Kanban card shows the same progress as the Agent Output modal
// Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask
@@ -440,4 +405,4 @@ export function AgentInfoPanel({
onOpenChange={setIsSummaryDialogOpen}
/>
);
}
});

View File

@@ -1,4 +1,5 @@
// @ts-nocheck
import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
@@ -32,7 +33,7 @@ interface CardActionsProps {
onApprovePlan?: () => void;
}
export function CardActions({
export const CardActions = memo(function CardActions({
feature,
isCurrentAutoTask,
hasContext,
@@ -344,4 +345,4 @@ export function CardActions({
)}
</div>
);
}
});

View File

@@ -1,10 +1,11 @@
// @ts-nocheck
import { useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { useShallow } from 'zustand/react/shallow';
/** Uniform badge style for all card badges */
const uniformBadgeClass =
@@ -18,7 +19,7 @@ interface CardBadgesProps {
* CardBadges - Shows error badges below the card header
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
*/
export function CardBadges({ feature }: CardBadgesProps) {
export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps) {
if (!feature.error) {
return null;
}
@@ -46,14 +47,19 @@ export function CardBadges({ feature }: CardBadgesProps) {
</TooltipProvider>
</div>
);
}
});
interface PriorityBadgesProps {
feature: Feature;
}
export function PriorityBadges({ feature }: PriorityBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore();
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore(
useShallow((state) => ({
enableDependencyBlocking: state.enableDependencyBlocking,
features: state.features,
}))
);
const [currentTime, setCurrentTime] = useState(() => Date.now());
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
@@ -223,4 +229,4 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
)}
</div>
);
}
});

View File

@@ -1,4 +1,5 @@
// @ts-nocheck
import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
@@ -7,7 +8,10 @@ interface CardContentSectionsProps {
useWorktrees: boolean;
}
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
export const CardContentSections = memo(function CardContentSections({
feature,
useWorktrees,
}: CardContentSectionsProps) {
return (
<>
{/* Target Branch Display */}
@@ -48,4 +52,4 @@ export function CardContentSections({ feature, useWorktrees }: CardContentSectio
})()}
</>
);
}
});

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import { useState } from 'react';
import { memo, useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -37,7 +37,7 @@ interface CardHeaderProps {
onSpawnTask?: () => void;
}
export function CardHeaderSection({
export const CardHeaderSection = memo(function CardHeaderSection({
feature,
isDraggable,
isCurrentAutoTask,
@@ -378,4 +378,4 @@ export function CardHeaderSection({
/>
</CardHeader>
);
}
});

View File

@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Feature, useAppStore } from '@/store/app-store';
import { useShallow } from 'zustand/react/shallow';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
import { CardContentSections } from './card-content-sections';
@@ -61,6 +62,7 @@ interface KanbanCardProps {
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
reduceEffects?: boolean;
// Selection mode props
isSelectionMode?: boolean;
isSelected?: boolean;
@@ -94,12 +96,18 @@ export const KanbanCard = memo(function KanbanCard({
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
reduceEffects = false,
isSelectionMode = false,
isSelected = false,
onToggleSelect,
selectionTarget = null,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const { useWorktrees, currentProject } = useAppStore(
useShallow((state) => ({
useWorktrees: state.useWorktrees,
currentProject: state.currentProject,
}))
);
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
@@ -140,9 +148,12 @@ export const KanbanCard = memo(function KanbanCard({
const hasError = feature.error && !isCurrentAutoTask;
const innerCardClasses = cn(
'kanban-card-content h-full relative shadow-sm',
'kanban-card-content h-full relative',
reduceEffects ? 'shadow-none' : 'shadow-sm',
'transition-all duration-200 ease-out',
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
isInteractive &&
!reduceEffects &&
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isCurrentAutoTask &&
cardBorderEnabled &&
@@ -215,6 +226,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Agent Info Panel */}
<AgentInfoPanel
feature={feature}
projectPath={currentProject?.path ?? ''}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}

View File

@@ -1,7 +1,7 @@
import { memo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
import type { CSSProperties, ReactNode, Ref, UIEvent } from 'react';
interface KanbanColumnProps {
id: string;
@@ -17,6 +17,11 @@ interface KanbanColumnProps {
hideScrollbar?: boolean;
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
width?: number;
contentRef?: Ref<HTMLDivElement>;
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
contentClassName?: string;
contentStyle?: CSSProperties;
disableItemSpacing?: boolean;
}
export const KanbanColumn = memo(function KanbanColumn({
@@ -31,6 +36,11 @@ export const KanbanColumn = memo(function KanbanColumn({
showBorder = true,
hideScrollbar = false,
width,
contentRef,
onScroll,
contentClassName,
contentStyle,
disableItemSpacing = false,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
@@ -78,14 +88,19 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Content */}
<div
className={cn(
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
'relative z-10 flex-1 overflow-y-auto p-2',
!disableItemSpacing && 'space-y-2.5',
hideScrollbar &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth',
// Add padding at bottom if there's a footer action
footerAction && 'pb-14'
footerAction && 'pb-14',
contentClassName
)}
ref={contentRef}
onScroll={onScroll}
style={contentStyle}
>
{children}
</div>

View File

@@ -15,6 +15,7 @@ import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
import { Markdown } from '@/components/ui/markdown';
import { useAppStore } from '@/store/app-store';
import { extractSummary } from '@/lib/log-parser';
import { useAgentOutput } from '@/hooks/queries';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
@@ -45,10 +46,30 @@ export function AgentOutputModal({
branchName,
}: AgentOutputModalProps) {
const isBacklogPlan = featureId.startsWith('backlog-plan:');
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
// Resolve project path - prefer prop, fallback to window.__currentProject
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
// Track additional content from WebSocket events (appended to query data)
const [streamedContent, setStreamedContent] = useState<string>('');
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
const [projectPath, setProjectPath] = useState<string>('');
// Use React Query for initial output loading
const { data: initialOutput = '', isLoading } = useAgentOutput(
resolvedProjectPath,
featureId,
open && !!resolvedProjectPath
);
// Reset streamed content when modal opens or featureId changes
useEffect(() => {
if (open) {
setStreamedContent('');
}
}, [open, featureId]);
// Combine initial output from query with streamed content from WebSocket
const output = initialOutput + streamedContent;
// Extract summary from output
const summary = useMemo(() => extractSummary(output), [output]);
@@ -57,7 +78,6 @@ export function AgentOutputModal({
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>('');
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -67,55 +87,6 @@ export function AgentOutputModal({
}
}, [output]);
// Load existing output from file
useEffect(() => {
if (!open) return;
const loadOutput = async () => {
const api = getElectronAPI();
if (!api) return;
setIsLoading(true);
try {
// Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
if (!resolvedProjectPath) {
setIsLoading(false);
return;
}
projectPathRef.current = resolvedProjectPath;
setProjectPath(resolvedProjectPath);
if (isBacklogPlan) {
setOutput('');
return;
}
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
if (result.success) {
setOutput(result.content || '');
} else {
setOutput('');
}
} else {
setOutput('');
}
} catch (error) {
console.error('Failed to load output:', error);
setOutput('');
} finally {
setIsLoading(false);
}
};
loadOutput();
}, [open, featureId, projectPathProp, isBacklogPlan]);
// Listen to auto mode events and update output
useEffect(() => {
if (!open) return;
@@ -274,8 +245,8 @@ export function AgentOutputModal({
}
if (newContent) {
// Only update local state - server is the single source of truth for file writes
setOutput((prev) => prev + newContent);
// Append new content from WebSocket to streamed content
setStreamedContent((prev) => prev + newContent);
}
});
@@ -426,16 +397,16 @@ export function AgentOutputModal({
{!isBacklogPlan && (
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
projectPath={resolvedProjectPath}
className="shrink-0 mx-3 my-2"
/>
)}
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
{resolvedProjectPath ? (
<GitDiffPanel
projectPath={projectPath}
projectPath={resolvedProjectPath}
featureId={branchName || featureId}
compact={false}
useWorktrees={useWorktrees}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
Dialog,
DialogContent,
@@ -17,6 +17,7 @@ import { GitPullRequest, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
interface WorktreeInfo {
path: string;
@@ -54,12 +55,21 @@ export function CreatePRDialog({
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Branch fetching state
const [branches, setBranches] = useState<string[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
// Use React Query for branch fetching - only enabled when dialog is open
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
open ? worktree?.path : undefined,
true // Include remote branches for PR base branch selection
);
// Filter out current worktree branch from the list
const branches = useMemo(() => {
if (!branchesData?.branches) return [];
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
}, [branchesData?.branches, worktree?.branch]);
// Common state reset function to avoid duplication
const resetState = useCallback(() => {
setTitle('');
@@ -72,44 +82,13 @@ export function CreatePRDialog({
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
setBranches([]);
}, [defaultBaseBranch]);
// Fetch branches for autocomplete
const fetchBranches = useCallback(async () => {
if (!worktree?.path) return;
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
return;
}
// Fetch both local and remote branches for PR base branch selection
const result = await api.worktree.listBranches(worktree.path, true);
if (result.success && result.result) {
// Extract branch names, filtering out the current worktree branch
const branchNames = result.result.branches
.map((b) => b.name)
.filter((name) => name !== worktree.branch);
setBranches(branchNames);
}
} catch {
// Silently fail - branches will default to main only
} finally {
setIsLoadingBranches(false);
}
}, [worktree?.path, worktree?.branch]);
// Reset state when dialog opens or worktree changes
useEffect(() => {
// Reset all state on both open and close
resetState();
if (open) {
// Fetch fresh branches when dialog opens
fetchBranches();
}
}, [open, worktree?.path, resetState, fetchBranches]);
}, [open, worktree?.path, resetState]);
const handleCreate = async () => {
if (!worktree) return;

View File

@@ -14,6 +14,7 @@ import { getElectronAPI } from '@/lib/electron';
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
@@ -94,6 +95,10 @@ export function useBoardActions({
} = useAppStore();
const autoMode = useAutoMode();
// React Query mutations for feature operations
const verifyFeatureMutation = useVerifyFeature(currentProject?.path ?? '');
const resumeFeatureMutation = useResumeFeature(currentProject?.path ?? '');
// Worktrees are created when adding/editing features with a branch name
// This ensures the worktree exists before the feature starts execution
@@ -553,28 +558,9 @@ export function useBoardActions({
const handleVerifyFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api?.autoMode) {
logger.error('Auto mode API not available');
return;
}
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
if (result.success) {
logger.info('Feature verification started successfully');
} else {
logger.error('Failed to verify feature:', result.error);
await loadFeatures();
}
} catch (error) {
logger.error('Error verifying feature:', error);
await loadFeatures();
}
verifyFeatureMutation.mutate(feature.id);
},
[currentProject, loadFeatures]
[currentProject, verifyFeatureMutation]
);
const handleResumeFeature = useCallback(
@@ -584,40 +570,9 @@ export function useBoardActions({
logger.error('No current project');
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
logger.error('Auto mode API not available');
return;
}
logger.info('Calling resumeFeature API...', {
projectPath: currentProject.path,
featureId: feature.id,
useWorktrees,
});
const result = await api.autoMode.resumeFeature(
currentProject.path,
feature.id,
useWorktrees
);
logger.info('resumeFeature result:', result);
if (result.success) {
logger.info('Feature resume started successfully');
} else {
logger.error('Failed to resume feature:', result.error);
await loadFeatures();
}
} catch (error) {
logger.error('Error resuming feature:', error);
await loadFeatures();
}
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees });
},
[currentProject, loadFeatures, useWorktrees]
[currentProject, resumeFeatureMutation, useWorktrees]
);
const handleManualVerify = useCallback(

View File

@@ -1,7 +1,11 @@
// @ts-nocheck
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
import {
createFeatureMap,
getBlockingDependenciesFromMap,
resolveDependencies,
} from '@automaker/dependency-resolver';
type ColumnId = Feature['status'];
@@ -32,6 +36,8 @@ export function useBoardColumnFeatures({
verified: [],
completed: [], // Completed features are shown in the archive modal, not as a column
};
const featureMap = createFeatureMap(features);
const runningTaskIds = new Set(runningAutoTasks);
// Filter features by search query (case-insensitive)
const normalizedQuery = searchQuery.toLowerCase().trim();
@@ -55,7 +61,7 @@ export function useBoardColumnFeatures({
filteredFeatures.forEach((f) => {
// If feature has a running agent, always show it in "in_progress"
const isRunning = runningAutoTasks.includes(f.id);
const isRunning = runningTaskIds.has(f.id);
// Check if feature matches the current worktree by branchName
// Features without branchName are considered unassigned (show only on primary worktree)
@@ -168,7 +174,6 @@ export function useBoardColumnFeatures({
const { orderedFeatures } = resolveDependencies(map.backlog);
// Get all features to check blocking dependencies against
const allFeatures = features;
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
// Sort blocked features to the end of the backlog
@@ -178,7 +183,7 @@ export function useBoardColumnFeatures({
const blocked: Feature[] = [];
for (const f of orderedFeatures) {
if (getBlockingDependencies(f, allFeatures).length > 0) {
if (getBlockingDependenciesFromMap(f, featureMap).length > 0) {
blocked.push(f);
} else {
unblocked.push(f);

View File

@@ -1,8 +1,18 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
/**
* Board Features Hook
*
* React Query-based hook for managing features on the board view.
* Handles feature loading, categories, and auto-mode event notifications.
*/
import { useState, useCallback, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
import { useFeatures } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardFeatures');
@@ -11,105 +21,15 @@ interface UseBoardFeaturesProps {
}
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const { features, setFeatures } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const queryClient = useQueryClient();
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
// Track previous project path to detect project switches
const prevProjectPathRef = useRef<string | null>(null);
const isInitialLoadRef = useRef(true);
const isSwitchingProjectRef = useRef(false);
// Load features using features API
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
const loadFeatures = useCallback(async () => {
if (!currentProject) return;
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
// Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features;
// If project switched, mark it but don't clear features yet
// We'll clear after successful API load to prevent data loss
if (isProjectSwitch) {
logger.info(`Project switch detected: ${previousPath} -> ${currentPath}`);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
}
// Update the ref to track current project
prevProjectPathRef.current = currentPath;
// Only show loading spinner on initial load to prevent board flash during reloads
if (isInitialLoadRef.current) {
setIsLoading(true);
}
try {
const api = getElectronAPI();
if (!api.features) {
logger.error('Features API not available');
// Keep cached features if API is unavailable
return;
}
const result = await api.features.getAll(currentProject.path);
if (result.success && result.features) {
const featuresWithIds = result.features.map((f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || 'backlog',
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || 'opus',
thinkingLevel: f.thinkingLevel || 'none',
}));
// Successfully loaded features - now safe to set them
setFeatures(featuresWithIds);
// Only clear categories on project switch AFTER successful load
if (isProjectSwitch) {
setPersistedCategories([]);
}
// Check for interrupted features and resume them
// This handles server restarts where features were in pipeline steps
if (api.autoMode?.resumeInterrupted) {
try {
await api.autoMode.resumeInterrupted(currentProject.path);
logger.info('Checked for interrupted features');
} catch (resumeError) {
logger.warn('Failed to check for interrupted features:', resumeError);
}
}
} else if (!result.success && result.error) {
logger.error('API returned error:', result.error);
// If it's a new project or the error indicates no features found,
// that's expected - start with empty array
if (isProjectSwitch) {
setFeatures([]);
setPersistedCategories([]);
}
// Otherwise keep cached features
}
} catch (error) {
logger.error('Failed to load features:', error);
// On error, keep existing cached features for the current project
// Only clear on project switch if we have no features from server
if (isProjectSwitch && cachedFeatures.length === 0) {
setFeatures([]);
setPersistedCategories([]);
}
} finally {
setIsLoading(false);
isInitialLoadRef.current = false;
isSwitchingProjectRef.current = false;
}
}, [currentProject, setFeatures]);
// Use React Query for features
const {
data: features = [],
isLoading,
refetch: loadFeatures,
} = useFeatures(currentProject?.path);
// Load persisted categories from file
const loadCategories = useCallback(async () => {
@@ -125,15 +45,12 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(parsed);
}
} else {
// File doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
} catch (error) {
logger.error('Failed to load categories:', error);
// If file doesn't exist, ensure categories are cleared
} catch {
setPersistedCategories([]);
}
}, [currentProject]);
}, [currentProject, loadFeatures]);
// Save a new category to the persisted categories file
const saveCategory = useCallback(
@@ -142,22 +59,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
// Read existing categories
let categories: string[] = [...persistedCategories];
// Add new category if it doesn't exist
if (!categories.includes(category)) {
categories.push(category);
categories.sort(); // Keep sorted
categories.sort();
// Write back to file
await api.writeFile(
`${currentProject.path}/.automaker/categories.json`,
JSON.stringify(categories, null, 2)
);
// Update state
setPersistedCategories(categories);
}
} catch (error) {
@@ -167,29 +79,8 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
[currentProject, persistedCategories]
);
// Subscribe to spec regeneration complete events to refresh kanban board
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
// Refresh the kanban board when spec regeneration completes for the current project
if (
event.type === 'spec_regeneration_complete' &&
currentProject &&
event.projectPath === currentProject.path
) {
logger.info('Spec regeneration complete, refreshing features');
loadFeatures();
}
});
return () => {
unsubscribe();
};
}, [currentProject, loadFeatures]);
// Listen for auto mode feature completion and errors to reload features
// Subscribe to auto mode events for notifications (ding sound, toasts)
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode || !currentProject) return;
@@ -229,28 +120,13 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const audio = new Audio('/sounds/ding.mp3');
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
}
} else if (event.type === 'plan_approval_required') {
// Reload features when plan is generated and requires approval
// This ensures the feature card shows the "Approve Plan" button
logger.info('Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
logger.info('Pipeline step started, reloading features...');
loadFeatures();
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
logger.info('Feature error, reloading features...', event.error);
// Remove from running tasks so it moves to the correct column
// Remove from running tasks
if (event.featureId) {
removeRunningTask(eventProjectId, event.featureId);
}
loadFeatures();
// Check for authentication errors and show a more helpful message
// Show error toast
const isAuthError =
event.errorType === 'authentication' ||
(event.error &&
@@ -272,22 +148,46 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
});
return unsubscribe;
}, [loadFeatures, currentProject]);
}, [currentProject]);
// Check for interrupted features on mount
useEffect(() => {
loadFeatures();
}, [loadFeatures]);
if (!currentProject) return;
// Load persisted categories on mount
const checkInterrupted = async () => {
const api = getElectronAPI();
if (api.autoMode?.resumeInterrupted) {
try {
await api.autoMode.resumeInterrupted(currentProject.path);
logger.info('Checked for interrupted features');
} catch (error) {
logger.warn('Failed to check for interrupted features:', error);
}
}
};
checkInterrupted();
}, [currentProject]);
// Load persisted categories on mount/project change
useEffect(() => {
loadCategories();
}, [loadCategories]);
// Clear categories when project changes
useEffect(() => {
setPersistedCategories([]);
}, [currentProject?.path]);
return {
features,
isLoading,
persistedCategories,
loadFeatures,
loadFeatures: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject?.path ?? ''),
});
},
loadCategories,
saveCategory,
};

View File

@@ -1,8 +1,10 @@
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardPersistence');
@@ -12,6 +14,7 @@ interface UseBoardPersistenceProps {
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
const { updateFeature } = useAppStore();
const queryClient = useQueryClient();
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
@@ -45,7 +48,21 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
feature: result.feature,
});
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
const updatedFeature = result.feature;
updateFeature(updatedFeature.id, updatedFeature);
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(features) => {
if (!features) return features;
return features.map((feature) =>
feature.id === updatedFeature.id ? updatedFeature : feature
);
}
);
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
} else if (!result.success) {
logger.error('API features.update failed', result);
}
@@ -53,7 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
logger.error('Failed to persist feature update:', error);
}
},
[currentProject, updateFeature]
[currentProject, updateFeature, queryClient]
);
// Persist feature creation to API
@@ -71,12 +88,16 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
const result = await api.features.create(currentProject.path, feature);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
}
} catch (error) {
logger.error('Failed to persist feature creation:', error);
}
},
[currentProject, updateFeature]
[currentProject, updateFeature, queryClient]
);
// Persist feature deletion to API
@@ -92,11 +113,15 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
}
await api.features.delete(currentProject.path, featureId);
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
} catch (error) {
logger.error('Failed to persist feature deletion:', error);
}
},
[currentProject]
[currentProject, queryClient]
);
return {

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode, UIEvent, RefObject } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
@@ -64,6 +65,199 @@ interface KanbanBoardProps {
className?: string;
}
const KANBAN_VIRTUALIZATION_THRESHOLD = 40;
const KANBAN_CARD_ESTIMATED_HEIGHT_PX = 220;
const KANBAN_CARD_GAP_PX = 10;
const KANBAN_OVERSCAN_COUNT = 6;
const VIRTUALIZATION_MEASURE_EPSILON_PX = 1;
const REDUCED_CARD_OPACITY_PERCENT = 85;
type VirtualListItem = { id: string };
interface VirtualListState<Item extends VirtualListItem> {
contentRef: RefObject<HTMLDivElement>;
onScroll: (event: UIEvent<HTMLDivElement>) => void;
itemIds: string[];
visibleItems: Item[];
totalHeight: number;
offsetTop: number;
startIndex: number;
shouldVirtualize: boolean;
registerItem: (id: string) => (node: HTMLDivElement | null) => void;
}
interface VirtualizedListProps<Item extends VirtualListItem> {
items: Item[];
isDragging: boolean;
estimatedItemHeight: number;
itemGap: number;
overscan: number;
virtualizationThreshold: number;
children: (state: VirtualListState<Item>) => ReactNode;
}
function findIndexForOffset(itemEnds: number[], offset: number): number {
let low = 0;
let high = itemEnds.length - 1;
let result = itemEnds.length;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (itemEnds[mid] >= offset) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return Math.min(result, itemEnds.length - 1);
}
// Virtualize long columns while keeping full DOM during drag interactions.
function VirtualizedList<Item extends VirtualListItem>({
items,
isDragging,
estimatedItemHeight,
itemGap,
overscan,
virtualizationThreshold,
children,
}: VirtualizedListProps<Item>) {
const contentRef = useRef<HTMLDivElement>(null);
const measurementsRef = useRef<Map<string, number>>(new Map());
const scrollRafRef = useRef<number | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [measureVersion, setMeasureVersion] = useState(0);
const itemIds = useMemo(() => items.map((item) => item.id), [items]);
const shouldVirtualize = !isDragging && items.length >= virtualizationThreshold;
const itemSizes = useMemo(() => {
return items.map((item) => {
const measured = measurementsRef.current.get(item.id);
const resolvedHeight = measured ?? estimatedItemHeight;
return resolvedHeight + itemGap;
});
}, [items, estimatedItemHeight, itemGap, measureVersion]);
const itemStarts = useMemo(() => {
let offset = 0;
return itemSizes.map((size) => {
const start = offset;
offset += size;
return start;
});
}, [itemSizes]);
const itemEnds = useMemo(() => {
return itemStarts.map((start, index) => start + itemSizes[index]);
}, [itemStarts, itemSizes]);
const totalHeight = itemEnds.length > 0 ? itemEnds[itemEnds.length - 1] : 0;
const { startIndex, endIndex, offsetTop } = useMemo(() => {
if (!shouldVirtualize || items.length === 0) {
return { startIndex: 0, endIndex: items.length, offsetTop: 0 };
}
const firstVisible = findIndexForOffset(itemEnds, scrollTop);
const lastVisible = findIndexForOffset(itemEnds, scrollTop + viewportHeight);
const overscannedStart = Math.max(0, firstVisible - overscan);
const overscannedEnd = Math.min(items.length, lastVisible + overscan + 1);
return {
startIndex: overscannedStart,
endIndex: overscannedEnd,
offsetTop: itemStarts[overscannedStart] ?? 0,
};
}, [shouldVirtualize, items.length, itemEnds, itemStarts, overscan, scrollTop, viewportHeight]);
const visibleItems = shouldVirtualize ? items.slice(startIndex, endIndex) : items;
const onScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
}
scrollRafRef.current = requestAnimationFrame(() => {
setScrollTop(target.scrollTop);
scrollRafRef.current = null;
});
}, []);
const registerItem = useCallback(
(id: string) => (node: HTMLDivElement | null) => {
if (!node || !shouldVirtualize) return;
const measuredHeight = node.getBoundingClientRect().height;
const previousHeight = measurementsRef.current.get(id);
if (
previousHeight === undefined ||
Math.abs(previousHeight - measuredHeight) > VIRTUALIZATION_MEASURE_EPSILON_PX
) {
measurementsRef.current.set(id, measuredHeight);
setMeasureVersion((value) => value + 1);
}
},
[shouldVirtualize]
);
useEffect(() => {
const container = contentRef.current;
if (!container || typeof window === 'undefined') return;
const updateHeight = () => {
setViewportHeight(container.clientHeight);
};
updateHeight();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}
const observer = new ResizeObserver(() => updateHeight());
observer.observe(container);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!shouldVirtualize) return;
const currentIds = new Set(items.map((item) => item.id));
for (const id of measurementsRef.current.keys()) {
if (!currentIds.has(id)) {
measurementsRef.current.delete(id);
}
}
}, [items, shouldVirtualize]);
useEffect(() => {
return () => {
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
}
};
}, []);
return (
<>
{children({
contentRef,
onScroll,
itemIds,
visibleItems,
totalHeight,
offsetTop,
startIndex,
shouldVirtualize,
registerItem,
})}
</>
);
}
export function KanbanBoard({
sensors,
collisionDetectionStrategy,
@@ -109,7 +303,7 @@ export function KanbanBoard({
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Get the keyboard shortcut for adding features
const { keyboardShortcuts } = useAppStore();
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
// Use responsive column widths based on window size
@@ -135,213 +329,307 @@ export function KanbanBoard({
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<KanbanColumn
<VirtualizedList
key={column.id}
id={column.id}
title={column.title}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
items={columnFeatures}
isDragging={isDragging}
estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX}
itemGap={KANBAN_CARD_GAP_PX}
overscan={KANBAN_OVERSCAN_COUNT}
virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD}
>
{({
contentRef,
onScroll,
itemIds,
visibleItems,
totalHeight,
offsetTop,
startIndex,
shouldVirtualize,
registerItem,
}) => (
<KanbanColumn
id={column.id}
title={column.title}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
contentRef={contentRef}
onScroll={shouldVirtualize ? onScroll : undefined}
disableItemSpacing={shouldVirtualize}
contentClassName="perf-contain"
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="selection-mode-button"
>
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
}
data-testid="selection-mode-button"
>
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
>
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => onEdit(feature)}
onDelete={() => onDelete(feature.id)}
onViewOutput={() => onViewOutput(feature)}
onVerify={() => onVerify(feature)}
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>
);
})}
</SortableContext>
</KanbanColumn>
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
{(() => {
const reduceEffects = shouldVirtualize;
const effectiveCardOpacity = reduceEffects
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
: backgroundSettings.cardOpacity;
const effectiveGlassmorphism =
backgroundSettings.cardGlassmorphism && !reduceEffects;
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{shouldVirtualize ? (
<div className="relative" style={{ height: totalHeight }}>
<div
className="absolute left-0 right-0"
style={{ transform: `translateY(${offsetTop}px)` }}
>
{visibleItems.map((feature, index) => {
const absoluteIndex = startIndex + index;
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && absoluteIndex < 10) {
shortcutKey =
absoluteIndex === 9 ? '0' : String(absoluteIndex + 1);
}
return (
<div
key={feature.id}
ref={registerItem(feature.id)}
style={{ marginBottom: `${KANBAN_CARD_GAP_PX}px` }}
>
<KanbanCard
feature={feature}
onEdit={() => onEdit(feature)}
onDelete={() => onDelete(feature.id)}
onViewOutput={() => onViewOutput(feature)}
onVerify={() => onVerify(feature)}
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() =>
onMoveBackToInProgress(feature)
}
onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
reduceEffects={reduceEffects}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() =>
onToggleFeatureSelection?.(feature.id)
}
/>
</div>
);
})}
</div>
</div>
) : (
columnFeatures.map((feature, index) => {
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
}
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => onEdit(feature)}
onDelete={() => onDelete(feature.id)}
onViewOutput={() => onViewOutput(feature)}
onVerify={() => onVerify(feature)}
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
reduceEffects={reduceEffects}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>
);
})
)}
</SortableContext>
);
})()}
</KanbanColumn>
)}
</VirtualizedList>
);
})}
</div>

View File

@@ -1,65 +1,46 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useMemo, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useAvailableEditors as useAvailableEditorsQuery } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import type { EditorInfo } from '@automaker/types';
const logger = createLogger('AvailableEditors');
// Re-export EditorInfo for convenience
export type { EditorInfo };
/**
* Hook for fetching and managing available editors
*
* Uses React Query for data fetching with caching.
* Provides a refresh function that clears server cache and re-detects editors.
*/
export function useAvailableEditors() {
const [editors, setEditors] = useState<EditorInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const fetchAvailableEditors = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getAvailableEditors) {
setIsLoading(false);
return;
}
const result = await api.worktree.getAvailableEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
}
} catch (error) {
logger.error('Failed to fetch available editors:', error);
} finally {
setIsLoading(false);
}
}, []);
const queryClient = useQueryClient();
const { data: editors = [], isLoading } = useAvailableEditorsQuery();
/**
* Refresh editors by clearing the server cache and re-detecting
* Mutation to refresh editors by clearing the server cache and re-detecting
* Use this when the user has installed/uninstalled editors
*/
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
mutationFn: async () => {
const api = getElectronAPI();
if (!api?.worktree?.refreshEditors) {
// Fallback to regular fetch if refresh not available
await fetchAvailableEditors();
return;
}
const result = await api.worktree.refreshEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
if (!result.success) {
throw new Error(result.error || 'Failed to refresh editors');
}
} catch (error) {
logger.error('Failed to refresh editors:', error);
} finally {
setIsRefreshing(false);
}
}, [fetchAvailableEditors]);
return result.result?.editors ?? [];
},
onSuccess: (newEditors) => {
// Update the cache with new editors
queryClient.setQueryData(queryKeys.worktrees.editors(), newEditors);
},
});
useEffect(() => {
fetchAvailableEditors();
}, [fetchAvailableEditors]);
const refresh = useCallback(() => {
refreshMutate();
}, [refreshMutate]);
return {
editors,

View File

@@ -1,66 +1,45 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import type { BranchInfo, GitRepoStatus } from '../types';
const logger = createLogger('Branches');
import { useWorktreeBranches } from '@/hooks/queries';
import type { GitRepoStatus } from '../types';
/**
* Hook for managing branch data with React Query
*
* Uses useWorktreeBranches for data fetching while maintaining
* the current interface for backward compatibility. Tracks which
* worktree path is currently being viewed and fetches branches on demand.
*/
export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
const [branchFilter, setBranchFilter] = useState('');
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
isGitRepo: true,
hasCommits: true,
});
/** Helper to reset branch state to initial values */
const resetBranchState = useCallback(() => {
setBranches([]);
setAheadCount(0);
setBehindCount(0);
}, []);
const {
data: branchData,
isLoading: isLoadingBranches,
refetch,
} = useWorktreeBranches(currentWorktreePath);
const branches = branchData?.branches ?? [];
const aheadCount = branchData?.aheadCount ?? 0;
const behindCount = branchData?.behindCount ?? 0;
// Use conservative defaults (false) until data is confirmed
// This prevents the UI from assuming git capabilities before the query completes
const gitRepoStatus: GitRepoStatus = {
isGitRepo: branchData?.isGitRepo ?? false,
hasCommits: branchData?.hasCommits ?? false,
};
const fetchBranches = useCallback(
async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
logger.warn('List branches API not available');
return;
}
const result = await api.worktree.listBranches(worktreePath);
if (result.success && result.result) {
setBranches(result.result.branches);
setAheadCount(result.result.aheadCount || 0);
setBehindCount(result.result.behindCount || 0);
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
} else if (result.code === 'NOT_GIT_REPO') {
// Not a git repository - clear branches silently without logging an error
resetBranchState();
setGitRepoStatus({ isGitRepo: false, hasCommits: false });
} else if (result.code === 'NO_COMMITS') {
// Git repo but no commits yet - clear branches silently without logging an error
resetBranchState();
setGitRepoStatus({ isGitRepo: true, hasCommits: false });
} else if (!result.success) {
// Other errors - log them
logger.warn('Failed to fetch branches:', result.error);
resetBranchState();
}
} catch (error) {
logger.error('Failed to fetch branches:', error);
resetBranchState();
// Reset git status to unknown state on network/API errors
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
} finally {
setIsLoadingBranches(false);
(worktreePath: string) => {
if (worktreePath === currentWorktreePath) {
// Same path - just refetch to get latest data
refetch();
} else {
// Different path - update the tracked path (triggers new query)
setCurrentWorktreePath(worktreePath);
}
},
[resetBranchState]
[currentWorktreePath, refetch]
);
const resetBranchFilter = useCallback(() => {

View File

@@ -3,128 +3,53 @@ import { useNavigate } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import {
useSwitchBranch,
usePullWorktree,
usePushWorktree,
useOpenInEditor,
} from '@/hooks/mutations';
import type { WorktreeInfo } from '../types';
const logger = createLogger('WorktreeActions');
// Error codes that need special user-friendly handling
const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const;
type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number];
// User-friendly messages for git status errors
const GIT_STATUS_ERROR_MESSAGES: Record<GitStatusErrorCode, string> = {
NOT_GIT_REPO: 'This directory is not a git repository',
NO_COMMITS: 'Repository has no commits yet. Create an initial commit first.',
};
/**
* Helper to handle git status errors with user-friendly messages.
* @returns true if the error was a git status error and was handled, false otherwise.
*/
function handleGitStatusError(result: { code?: string; error?: string }): boolean {
const errorCode = result.code as GitStatusErrorCode | undefined;
if (errorCode && GIT_STATUS_ERROR_CODES.includes(errorCode)) {
toast.info(GIT_STATUS_ERROR_MESSAGES[errorCode] || result.error);
return true;
}
return false;
}
interface UseWorktreeActionsOptions {
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
fetchBranches: (worktreePath: string) => Promise<void>;
}
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
export function useWorktreeActions() {
const navigate = useNavigate();
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [isActivating, setIsActivating] = useState(false);
// Use React Query mutations
const switchBranchMutation = useSwitchBranch();
const pullMutation = usePullWorktree();
const pushMutation = usePushWorktree();
const openInEditorMutation = useOpenInEditor();
const handleSwitchBranch = useCallback(
async (worktree: WorktreeInfo, branchName: string) => {
if (isSwitching || branchName === worktree.branch) return;
setIsSwitching(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.switchBranch) {
toast.error('Switch branch API not available');
return;
}
const result = await api.worktree.switchBranch(worktree.path, branchName);
if (result.success && result.result) {
toast.success(result.result.message);
fetchWorktrees();
} else {
if (handleGitStatusError(result)) return;
toast.error(result.error || 'Failed to switch branch');
}
} catch (error) {
logger.error('Switch branch failed:', error);
toast.error('Failed to switch branch');
} finally {
setIsSwitching(false);
}
if (switchBranchMutation.isPending || branchName === worktree.branch) return;
switchBranchMutation.mutate({
worktreePath: worktree.path,
branchName,
});
},
[isSwitching, fetchWorktrees]
[switchBranchMutation]
);
const handlePull = useCallback(
async (worktree: WorktreeInfo) => {
if (isPulling) return;
setIsPulling(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
toast.error('Pull API not available');
return;
}
const result = await api.worktree.pull(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
fetchWorktrees();
} else {
if (handleGitStatusError(result)) return;
toast.error(result.error || 'Failed to pull latest changes');
}
} catch (error) {
logger.error('Pull failed:', error);
toast.error('Failed to pull latest changes');
} finally {
setIsPulling(false);
}
if (pullMutation.isPending) return;
pullMutation.mutate(worktree.path);
},
[isPulling, fetchWorktrees]
[pullMutation]
);
const handlePush = useCallback(
async (worktree: WorktreeInfo) => {
if (isPushing) return;
setIsPushing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error('Push API not available');
return;
}
const result = await api.worktree.push(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
fetchBranches(worktree.path);
fetchWorktrees();
} else {
if (handleGitStatusError(result)) return;
toast.error(result.error || 'Failed to push changes');
}
} catch (error) {
logger.error('Push failed:', error);
toast.error('Failed to push changes');
} finally {
setIsPushing(false);
}
if (pushMutation.isPending) return;
pushMutation.mutate({
worktreePath: worktree.path,
});
},
[isPushing, fetchBranches, fetchWorktrees]
[pushMutation]
);
const handleOpenInIntegratedTerminal = useCallback(
@@ -140,23 +65,15 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
[navigate]
);
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
logger.warn('Open in editor API not available');
return;
}
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {
toast.error(result.error);
}
} catch (error) {
logger.error('Open in editor failed:', error);
}
}, []);
const handleOpenInEditor = useCallback(
async (worktree: WorktreeInfo, editorCommand?: string) => {
openInEditorMutation.mutate({
worktreePath: worktree.path,
editorCommand,
});
},
[openInEditorMutation]
);
const handleOpenInExternalTerminal = useCallback(
async (worktree: WorktreeInfo, terminalId?: string) => {
@@ -180,9 +97,9 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
);
return {
isPulling,
isPushing,
isSwitching,
isPulling: pullMutation.isPending,
isPushing: pushMutation.isPending,
isSwitching: switchBranchMutation.isPending,
isActivating,
setIsActivating,
handleSwitchBranch,

View File

@@ -1,12 +1,11 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useEffect, useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useWorktrees as useWorktreesQuery } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import { pathsEqual } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
const logger = createLogger('Worktrees');
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
@@ -18,62 +17,46 @@ export function useWorktrees({
refreshTrigger = 0,
onRemovedWorktrees,
}: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const queryClient = useQueryClient();
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(
async (options?: { silent?: boolean }) => {
if (!projectPath) return;
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(true);
}
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
logger.warn('Worktree API not available');
return;
}
// Pass forceRefreshGitHub when this is a manual refresh (not silent polling)
// This clears the GitHub remote cache so users can re-detect after adding a remote
const forceRefreshGitHub = !silent;
const result = await api.worktree.listAll(projectPath, true, forceRefreshGitHub);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
// Return removed worktrees so they can be handled by the caller
return result.removedWorktrees;
} catch (error) {
logger.error('Failed to fetch worktrees:', error);
return undefined;
} finally {
if (!silent) {
setIsLoading(false);
}
}
},
[projectPath, setWorktreesInStore]
);
// Use the React Query hook
const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
// Sync worktrees to Zustand store when they change
useEffect(() => {
fetchWorktrees();
}, [fetchWorktrees]);
if (worktrees.length > 0) {
setWorktreesInStore(projectPath, worktrees);
}
}, [worktrees, projectPath, setWorktreesInStore]);
// Handle removed worktrees callback when data changes
const prevRemovedWorktreesRef = useRef<string | null>(null);
useEffect(() => {
if (data?.removedWorktrees && data.removedWorktrees.length > 0) {
// Create a stable key to avoid duplicate callbacks
const key = JSON.stringify(data.removedWorktrees);
if (key !== prevRemovedWorktreesRef.current) {
prevRemovedWorktreesRef.current = key;
onRemovedWorktrees?.(data.removedWorktrees);
}
}
}, [data?.removedWorktrees, onRemovedWorktrees]);
// Handle refresh trigger
useEffect(() => {
if (refreshTrigger > 0) {
fetchWorktrees().then((removedWorktrees) => {
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
// Invalidate and refetch to get fresh data including any removed worktrees
queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPath),
});
}
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
}, [refreshTrigger, projectPath, queryClient]);
// Use a ref to track the current worktree to avoid running validation
// when selection changes (which could cause a race condition with stale worktrees list)
@@ -111,6 +94,14 @@ export function useWorktrees({
[projectPath, setCurrentWorktree]
);
// fetchWorktrees for backward compatibility - now just triggers a refetch
const fetchWorktrees = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPath),
});
return refetch();
}, [projectPath, queryClient, refetch]);
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))

View File

@@ -6,6 +6,7 @@ import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
import { useWorktreeInitScript } from '@/hooks/queries';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -85,10 +86,7 @@ export function WorktreePanel({
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
} = useWorktreeActions({
fetchWorktrees,
fetchBranches,
});
} = useWorktreeActions();
const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds,
@@ -156,8 +154,9 @@ export function WorktreePanel({
[currentProject, projectPath, isAutoModeRunningForWorktree]
);
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
// Check if init script exists for the project using React Query
const { data: initScriptData } = useWorktreeInitScript(projectPath);
const hasInitScript = initScriptData?.exists ?? false;
// View changes dialog state
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
@@ -171,25 +170,6 @@ export function WorktreePanel({
const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
useEffect(() => {
if (!projectPath) {
setHasInitScript(false);
return;
}
const checkInitScript = async () => {
try {
const api = getHttpApiClient();
const result = await api.worktree.getInitScript(projectPath);
setHasInitScript(result.success && result.exists);
} catch {
setHasInitScript(false);
}
};
checkInitScript();
}, [projectPath]);
const isMobile = useIsMobile();
// Periodic interval check (5 seconds) to detect branch changes on disk

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { UIEvent } from 'react';
import { useAppStore } from '@/store/app-store';
import { useShallow } from 'zustand/react/shallow';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -22,6 +24,10 @@ import {
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
const CHAT_SESSION_ROW_HEIGHT_PX = 84;
const CHAT_SESSION_OVERSCAN_COUNT = 6;
const CHAT_SESSION_LIST_PADDING_PX = 8;
export function ChatHistory() {
const {
chatSessions,
@@ -34,29 +40,117 @@ export function ChatHistory() {
unarchiveChatSession,
deleteChatSession,
setChatHistoryOpen,
} = useAppStore();
} = useAppStore(
useShallow((state) => ({
chatSessions: state.chatSessions,
currentProject: state.currentProject,
currentChatSession: state.currentChatSession,
chatHistoryOpen: state.chatHistoryOpen,
createChatSession: state.createChatSession,
setCurrentChatSession: state.setCurrentChatSession,
archiveChatSession: state.archiveChatSession,
unarchiveChatSession: state.unarchiveChatSession,
deleteChatSession: state.deleteChatSession,
setChatHistoryOpen: state.setChatHistoryOpen,
}))
);
const [searchQuery, setSearchQuery] = useState('');
const [showArchived, setShowArchived] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const scrollRafRef = useRef<number | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
if (!currentProject) {
return null;
}
const normalizedQuery = searchQuery.trim().toLowerCase();
const currentProjectId = currentProject?.id;
// Filter sessions for current project
const projectSessions = chatSessions.filter((session) => session.projectId === currentProject.id);
const projectSessions = useMemo(() => {
if (!currentProjectId) return [];
return chatSessions.filter((session) => session.projectId === currentProjectId);
}, [chatSessions, currentProjectId]);
// Filter by search query and archived status
const filteredSessions = projectSessions.filter((session) => {
const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase());
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
return matchesSearch && matchesArchivedStatus;
});
const filteredSessions = useMemo(() => {
return projectSessions.filter((session) => {
const matchesSearch = session.title.toLowerCase().includes(normalizedQuery);
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
return matchesSearch && matchesArchivedStatus;
});
}, [projectSessions, normalizedQuery, showArchived]);
// Sort by most recently updated
const sortedSessions = filteredSessions.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
const sortedSessions = useMemo(() => {
return [...filteredSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}, [filteredSessions]);
const totalHeight =
sortedSessions.length * CHAT_SESSION_ROW_HEIGHT_PX + CHAT_SESSION_LIST_PADDING_PX * 2;
const startIndex = Math.max(
0,
Math.floor(scrollTop / CHAT_SESSION_ROW_HEIGHT_PX) - CHAT_SESSION_OVERSCAN_COUNT
);
const endIndex = Math.min(
sortedSessions.length,
Math.ceil((scrollTop + viewportHeight) / CHAT_SESSION_ROW_HEIGHT_PX) +
CHAT_SESSION_OVERSCAN_COUNT
);
const offsetTop = startIndex * CHAT_SESSION_ROW_HEIGHT_PX;
const visibleSessions = sortedSessions.slice(startIndex, endIndex);
const handleScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
}
scrollRafRef.current = requestAnimationFrame(() => {
setScrollTop(target.scrollTop);
scrollRafRef.current = null;
});
}, []);
useEffect(() => {
const container = listRef.current;
if (!container || typeof window === 'undefined') return;
const updateHeight = () => {
setViewportHeight(container.clientHeight);
};
updateHeight();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}
const observer = new ResizeObserver(() => updateHeight());
observer.observe(container);
return () => observer.disconnect();
}, [chatHistoryOpen]);
useEffect(() => {
if (!chatHistoryOpen) return;
setScrollTop(0);
if (listRef.current) {
listRef.current.scrollTop = 0;
}
}, [chatHistoryOpen, normalizedQuery, showArchived, currentProjectId]);
useEffect(() => {
return () => {
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
}
};
}, []);
if (!currentProjectId) {
return null;
}
const handleCreateNewChat = () => {
createChatSession();
@@ -151,7 +245,11 @@ export function ChatHistory() {
</div>
{/* Chat Sessions List */}
<div className="flex-1 overflow-y-auto">
<div
className="flex-1 overflow-y-auto perf-contain"
ref={listRef}
onScroll={handleScroll}
>
{sortedSessions.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery ? (
@@ -163,60 +261,75 @@ export function ChatHistory() {
)}
</div>
) : (
<div className="p-2">
{sortedSessions.map((session) => (
<div
key={session.id}
className={cn(
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
currentChatSession?.id === session.id && 'bg-accent'
)}
onClick={() => handleSelectSession(session)}
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{session.title}</h3>
<p className="text-xs text-muted-foreground truncate">
{session.messages.length} messages
</p>
<p className="text-xs text-muted-foreground">
{new Date(session.updatedAt).toLocaleDateString()}
</p>
</div>
<div
className="relative"
style={{
height: totalHeight,
paddingTop: CHAT_SESSION_LIST_PADDING_PX,
paddingBottom: CHAT_SESSION_LIST_PADDING_PX,
}}
>
<div
className="absolute left-0 right-0"
style={{ transform: `translateY(${offsetTop}px)` }}
>
{visibleSessions.map((session) => (
<div
key={session.id}
className={cn(
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
currentChatSession?.id === session.id && 'bg-accent'
)}
style={{ height: CHAT_SESSION_ROW_HEIGHT_PX }}
onClick={() => handleSelectSession(session)}
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{session.title}</h3>
<p className="text-xs text-muted-foreground truncate">
{session.messages.length} messages
</p>
<p className="text-xs text-muted-foreground">
{new Date(session.updatedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{session.archived ? (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{session.archived ? (
<DropdownMenuItem
onClick={(e) => handleUnarchiveSession(session.id, e)}
>
<ArchiveRestore className="w-4 h-4 mr-2" />
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) => handleArchiveSession(session.id, e)}
>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleUnarchiveSession(session.id, e)}
onClick={(e) => handleDeleteSession(session.id, e)}
className="text-destructive"
>
<ArchiveRestore className="w-4 h-4 mr-2" />
Unarchive
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={(e) => handleArchiveSession(session.id, e)}>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteSession(session.id, e)}
className="text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
))}
))}
</div>
</div>
)}
</div>

View File

@@ -2,6 +2,7 @@
import { useState, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -10,6 +11,7 @@ import { LoadingState } from '@/components/ui/loading-state';
import { ErrorState } from '@/components/ui/error-state';
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
import { toast } from 'sonner';
import { queryKeys } from '@/lib/query-keys';
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
@@ -36,6 +38,7 @@ export function GitHubIssuesView() {
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
const queryClient = useQueryClient();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
@@ -153,6 +156,10 @@ export function GitHubIssuesView() {
const result = await api.features.create(currentProject.path, feature);
if (result.success) {
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
toast.success(`Created task: ${issue.title}`);
} else {
toast.error(result.error || 'Failed to create task');
@@ -163,7 +170,7 @@ export function GitHubIssuesView() {
toast.error(err instanceof Error ? err.message : 'Failed to create task');
}
},
[currentProject?.path, currentBranch]
[currentProject?.path, currentBranch, queryClient]
);
if (loading) {

View File

@@ -1,79 +1,29 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
/**
* GitHub Issues Hook
*
* React Query-based hook for fetching GitHub issues.
*/
const logger = createLogger('GitHubIssues');
import { useAppStore } from '@/store/app-store';
import { useGitHubIssues as useGitHubIssuesQuery } from '@/hooks/queries';
export function useGithubIssues() {
const { currentProject } = useAppStore();
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const fetchIssues = useCallback(async () => {
if (!currentProject?.path) {
if (isMountedRef.current) {
setError('No project selected');
setLoading(false);
}
return;
}
try {
if (isMountedRef.current) {
setError(null);
}
const api = getElectronAPI();
if (api.github) {
const result = await api.github.listIssues(currentProject.path);
if (isMountedRef.current) {
if (result.success) {
setOpenIssues(result.openIssues || []);
setClosedIssues(result.closedIssues || []);
} else {
setError(result.error || 'Failed to fetch issues');
}
}
}
} catch (err) {
if (isMountedRef.current) {
logger.error('Error fetching issues:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
}
} finally {
if (isMountedRef.current) {
setLoading(false);
setRefreshing(false);
}
}
}, [currentProject?.path]);
useEffect(() => {
isMountedRef.current = true;
fetchIssues();
return () => {
isMountedRef.current = false;
};
}, [fetchIssues]);
const refresh = useCallback(() => {
if (isMountedRef.current) {
setRefreshing(true);
}
fetchIssues();
}, [fetchIssues]);
const {
data,
isLoading: loading,
isFetching: refreshing,
error,
refetch: refresh,
} = useGitHubIssuesQuery(currentProject?.path);
return {
openIssues,
closedIssues,
openIssues: data?.openIssues ?? [],
closedIssues: data?.closedIssues ?? [],
loading,
refreshing,
error,
error: error instanceof Error ? error.message : error ? String(error) : null,
refresh,
};
}

View File

@@ -1,9 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI, GitHubComment } from '@/lib/electron';
const logger = createLogger('IssueComments');
import { useMemo, useCallback } from 'react';
import type { GitHubComment } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useGitHubIssueComments } from '@/hooks/queries';
interface UseIssueCommentsResult {
comments: GitHubComment[];
@@ -18,119 +16,36 @@ interface UseIssueCommentsResult {
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
const { currentProject } = useAppStore();
const [comments, setComments] = useState<GitHubComment[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasNextPage, setHasNextPage] = useState(false);
const [endCursor, setEndCursor] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const fetchComments = useCallback(
async (cursor?: string) => {
if (!currentProject?.path || !issueNumber) {
return;
}
// Use React Query infinite query
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch, error } =
useGitHubIssueComments(currentProject?.path, issueNumber ?? undefined);
const isLoadingMore = !!cursor;
// Flatten all pages into a single comments array
const comments = useMemo(() => {
return data?.pages.flatMap((page) => page.comments) ?? [];
}, [data?.pages]);
try {
if (isMountedRef.current) {
setError(null);
if (isLoadingMore) {
setLoadingMore(true);
} else {
setLoading(true);
}
}
const api = getElectronAPI();
if (api.github) {
const result = await api.github.getIssueComments(
currentProject.path,
issueNumber,
cursor
);
if (isMountedRef.current) {
if (result.success) {
if (isLoadingMore) {
// Append new comments
setComments((prev) => [...prev, ...(result.comments || [])]);
} else {
// Replace all comments
setComments(result.comments || []);
}
setTotalCount(result.totalCount || 0);
setHasNextPage(result.hasNextPage || false);
setEndCursor(result.endCursor);
} else {
setError(result.error || 'Failed to fetch comments');
}
}
}
} catch (err) {
if (isMountedRef.current) {
logger.error('Error fetching comments:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch comments');
}
} finally {
if (isMountedRef.current) {
setLoading(false);
setLoadingMore(false);
}
}
},
[currentProject?.path, issueNumber]
);
// Reset and fetch when issue changes
useEffect(() => {
isMountedRef.current = true;
if (issueNumber) {
// Reset state when issue changes
setComments([]);
setTotalCount(0);
setHasNextPage(false);
setEndCursor(undefined);
setError(null);
fetchComments();
} else {
// Clear comments when no issue is selected
setComments([]);
setTotalCount(0);
setHasNextPage(false);
setEndCursor(undefined);
setLoading(false);
setError(null);
}
return () => {
isMountedRef.current = false;
};
}, [issueNumber, fetchComments]);
// Get total count from the first page
const totalCount = data?.pages[0]?.totalCount ?? 0;
const loadMore = useCallback(() => {
if (hasNextPage && endCursor && !loadingMore) {
fetchComments(endCursor);
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, endCursor, loadingMore, fetchComments]);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const refresh = useCallback(() => {
setComments([]);
setEndCursor(undefined);
fetchComments();
}, [fetchComments]);
refetch();
}, [refetch]);
return {
comments,
totalCount,
loading,
loadingMore,
hasNextPage,
error,
loading: isLoading,
loadingMore: isFetchingNextPage,
hasNextPage: hasNextPage ?? false,
error: error instanceof Error ? error.message : null,
loadMore,
refresh,
};

View File

@@ -13,6 +13,7 @@ import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
import { useValidateIssue, useMarkValidationViewed } from '@/hooks/mutations';
const logger = createLogger('IssueValidation');
@@ -46,6 +47,10 @@ export function useIssueValidation({
new Map()
);
const audioRef = useRef<HTMLAudioElement | null>(null);
// React Query mutations
const validateIssueMutation = useValidateIssue(currentProject?.path ?? '');
const markViewedMutation = useMarkValidationViewed(currentProject?.path ?? '');
// Refs for stable event handler (avoids re-subscribing on state changes)
const selectedIssueRef = useRef<GitHubIssue | null>(null);
const showValidationDialogRef = useRef(false);
@@ -240,7 +245,7 @@ export function useIssueValidation({
}
// Check if already validating this issue
if (validatingIssues.has(issue.number)) {
if (validatingIssues.has(issue.number) || validateIssueMutation.isPending) {
toast.info(`Validation already in progress for issue #${issue.number}`);
return;
}
@@ -254,11 +259,6 @@ export function useIssueValidation({
return;
}
// Start async validation in background (no dialog - user will see badge when done)
toast.info(`Starting validation for issue #${issue.number}`, {
description: 'You will be notified when the analysis is complete',
});
// Use provided model override or fall back to phaseModels.validationModel
// Extract model string and thinking level from PhaseModelEntry (handles both old string format and new object format)
const effectiveModelEntry = modelEntry
@@ -276,40 +276,22 @@ export function useIssueValidation({
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
try {
const api = getElectronAPI();
if (api.github?.validateIssue) {
const validationInput = {
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
comments, // Include comments if provided
linkedPRs, // Include linked PRs if provided
};
const result = await api.github.validateIssue(
currentProject.path,
validationInput,
modelToUse,
thinkingLevelToUse,
reasoningEffortToUse
);
if (!result.success) {
toast.error(result.error || 'Failed to start validation');
}
// On success, the result will come through the event stream
}
} catch (err) {
logger.error('Validation error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
}
// Use mutation to trigger validation (toast is handled by mutation)
validateIssueMutation.mutate({
issue,
model: modelToUse,
thinkingLevel: thinkingLevelToUse,
reasoningEffort: reasoningEffortToUse,
comments,
linkedPRs,
});
},
[
currentProject?.path,
validatingIssues,
cachedValidations,
phaseModels.validationModel,
validateIssueMutation,
onValidationResultChange,
onShowValidationDialogChange,
]
@@ -325,10 +307,8 @@ export function useIssueValidation({
// Mark as viewed if not already viewed
if (!cached.viewedAt && currentProject?.path) {
try {
const api = getElectronAPI();
if (api.github?.markValidationViewed) {
await api.github.markValidationViewed(currentProject.path, issue.number);
markViewedMutation.mutate(issue.number, {
onSuccess: () => {
// Update local state
setCachedValidations((prev) => {
const next = new Map(prev);
@@ -341,16 +321,15 @@ export function useIssueValidation({
}
return next;
});
}
} catch (err) {
logger.error('Failed to mark validation as viewed:', err);
}
},
});
}
}
},
[
cachedValidations,
currentProject?.path,
markViewedMutation,
onValidationResultChange,
onShowValidationDialogChange,
]
@@ -361,5 +340,6 @@ export function useIssueValidation({
cachedValidations,
handleValidateIssue,
handleViewCachedValidation,
isValidating: validateIssueMutation.isPending,
};
}

View File

@@ -1,60 +1,37 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
/**
* GitHub PRs View
*
* Displays pull requests using React Query for data fetching.
*/
import { useState, useCallback } from 'react';
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI, GitHubPR } from '@/lib/electron';
import { getElectronAPI, type GitHubPR } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
const logger = createLogger('GitHubPRsView');
import { useGitHubPRs } from '@/hooks/queries';
export function GitHubPRsView() {
const [openPRs, setOpenPRs] = useState<GitHubPR[]>([]);
const [mergedPRs, setMergedPRs] = useState<GitHubPR[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
const { currentProject } = useAppStore();
const fetchPRs = useCallback(async () => {
if (!currentProject?.path) {
setError('No project selected');
setLoading(false);
return;
}
const {
data,
isLoading: loading,
isFetching: refreshing,
error,
refetch,
} = useGitHubPRs(currentProject?.path);
try {
setError(null);
const api = getElectronAPI();
if (api.github) {
const result = await api.github.listPRs(currentProject.path);
if (result.success) {
setOpenPRs(result.openPRs || []);
setMergedPRs(result.mergedPRs || []);
} else {
setError(result.error || 'Failed to fetch pull requests');
}
}
} catch (err) {
logger.error('Error fetching PRs:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch pull requests');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [currentProject?.path]);
useEffect(() => {
fetchPRs();
}, [fetchPRs]);
const openPRs = data?.openPRs ?? [];
const mergedPRs = data?.mergedPRs ?? [];
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchPRs();
}, [fetchPRs]);
refetch();
}, [refetch]);
const handleOpenInGitHub = useCallback((url: string) => {
const api = getElectronAPI();
@@ -99,7 +76,9 @@ export function GitHubPRsView() {
<GitPullRequest className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">Failed to Load Pull Requests</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
<p className="text-muted-foreground max-w-md mb-4">
{error instanceof Error ? error.message : 'Failed to fetch pull requests'}
</p>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
@@ -197,9 +176,9 @@ export function GitHubPRsView() {
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2 min-w-0">
{selectedPR.state === 'MERGED' ? (
<GitMerge className="h-4 w-4 text-purple-500 flex-shrink-0" />
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
) : (
<GitPullRequest className="h-4 w-4 text-green-500 flex-shrink-0" />
<GitPullRequest className="h-4 w-4 text-green-500 shrink-0" />
)}
<span className="text-sm font-medium truncate">
#{selectedPR.number} {selectedPR.title}
@@ -210,7 +189,7 @@ export function GitHubPRsView() {
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
@@ -342,16 +321,16 @@ function PRRow({
onClick={onClick}
>
{pr.state === 'MERGED' ? (
<GitMerge className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
<GitMerge className="h-4 w-4 text-purple-500 mt-0.5 shrink-0" />
) : (
<GitPullRequest className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<GitPullRequest className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{pr.title}</span>
{pr.isDraft && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground flex-shrink-0">
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground shrink-0">
Draft
</span>
)}
@@ -402,7 +381,7 @@ function PRRow({
<Button
variant="ghost"
size="sm"
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
className="shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();

View File

@@ -1,6 +1,7 @@
// @ts-nocheck
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { useShallow } from 'zustand/react/shallow';
import { GraphView } from './graph-view';
import {
EditFeatureDialog,
@@ -40,7 +41,20 @@ export function GraphViewPage() {
addFeatureUseSelectedWorktreeBranch,
planUseSelectedWorktreeBranch,
setPlanUseSelectedWorktreeBranch,
} = useAppStore();
} = useAppStore(
useShallow((state) => ({
currentProject: state.currentProject,
updateFeature: state.updateFeature,
getCurrentWorktree: state.getCurrentWorktree,
getWorktrees: state.getWorktrees,
setWorktrees: state.setWorktrees,
setCurrentWorktree: state.setCurrentWorktree,
defaultSkipTests: state.defaultSkipTests,
addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
setPlanUseSelectedWorktreeBranch: state.setPlanUseSelectedWorktreeBranch,
}))
);
// Ensure worktrees are loaded when landing directly on graph view
useWorktrees({ projectPath: currentProject?.path ?? '' });

View File

@@ -4,6 +4,7 @@ import type { EdgeProps } from '@xyflow/react';
import { cn } from '@/lib/utils';
import { Feature } from '@/store/app-store';
import { Trash2 } from 'lucide-react';
import { GRAPH_RENDER_MODE_COMPACT, type GraphRenderMode } from '../constants';
export interface DependencyEdgeData {
sourceStatus: Feature['status'];
@@ -11,6 +12,7 @@ export interface DependencyEdgeData {
isHighlighted?: boolean;
isDimmed?: boolean;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
renderMode?: GraphRenderMode;
}
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
@@ -61,6 +63,7 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
const isHighlighted = edgeData?.isHighlighted ?? false;
const isDimmed = edgeData?.isDimmed ?? false;
const isCompact = edgeData?.renderMode === GRAPH_RENDER_MODE_COMPACT;
const edgeColor = isHighlighted
? 'var(--brand-500)'
@@ -86,6 +89,51 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
}
};
if (isCompact) {
return (
<>
<BaseEdge
id={id}
path={edgePath}
className={cn('transition-opacity duration-200', isDimmed && 'graph-edge-dimmed')}
style={{
strokeWidth: selected ? 2 : 1.5,
stroke: selected ? 'var(--status-error)' : edgeColor,
strokeDasharray: isCompleted ? 'none' : '5 5',
opacity: isDimmed ? 0.2 : 1,
}}
/>
{selected && edgeData?.onDeleteDependency && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'auto',
zIndex: 1000,
}}
>
<button
onClick={handleDelete}
className={cn(
'flex items-center justify-center',
'w-6 h-6 rounded-full',
'bg-[var(--status-error)] hover:bg-[var(--status-error)]/80',
'text-white shadow-lg',
'transition-all duration-150',
'hover:scale-110'
)}
title="Delete dependency"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</EdgeLabelRenderer>
)}
</>
);
}
return (
<>
{/* Invisible wider path for hover detection */}

View File

@@ -18,6 +18,7 @@ import {
Trash2,
} from 'lucide-react';
import { TaskNodeData } from '../hooks/use-graph-nodes';
import { GRAPH_RENDER_MODE_COMPACT } from '../constants';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -109,9 +110,11 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Background/theme settings with defaults
const cardOpacity = data.cardOpacity ?? 100;
const glassmorphism = data.cardGlassmorphism ?? true;
const shouldUseGlassmorphism = data.cardGlassmorphism ?? true;
const cardBorderEnabled = data.cardBorderEnabled ?? true;
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
const isCompact = data.renderMode === GRAPH_RENDER_MODE_COMPACT;
const glassmorphism = shouldUseGlassmorphism && !isCompact;
// Get the border color based on status and error state
const borderColor = data.error
@@ -129,6 +132,99 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Get computed border style
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
if (isCompact) {
return (
<>
<Handle
id="target"
type="target"
position={Position.Left}
isConnectable={true}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500',
isDimmed && 'opacity-30'
)}
/>
<div
className={cn(
'min-w-[200px] max-w-[240px] rounded-lg shadow-sm relative',
'transition-all duration-200',
selected && 'ring-2 ring-brand-500 ring-offset-1 ring-offset-background',
isMatched && 'graph-node-matched',
isHighlighted && !isMatched && 'graph-node-highlighted',
isDimmed && 'graph-node-dimmed'
)}
style={borderStyle}
>
<div
className="absolute inset-0 rounded-lg bg-card"
style={{ opacity: cardOpacity / 100 }}
/>
<div className={cn('relative flex items-center gap-2 px-2.5 py-2', config.bgClass)}>
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
<span className={cn('text-[11px] font-medium', config.colorClass)}>{config.label}</span>
{priorityConf && (
<span
className={cn(
'ml-auto text-[9px] font-bold px-1.5 py-0.5 rounded',
priorityConf.colorClass
)}
>
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
</span>
)}
</div>
<div className="relative px-2.5 py-2">
<p
className={cn(
'text-xs text-foreground line-clamp-2',
data.title ? 'font-medium' : 'font-semibold'
)}
>
{data.title || data.description}
</p>
{data.title && data.description && (
<p className="text-[11px] text-muted-foreground line-clamp-1 mt-1">
{data.description}
</p>
)}
{data.isRunning && (
<div className="mt-2 flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="inline-flex w-1.5 h-1.5 rounded-full bg-[var(--status-in-progress)]" />
Running
</div>
)}
{isStopped && (
<div className="mt-2 flex items-center gap-2 text-[10px] text-[var(--status-warning)]">
<span className="inline-flex w-1.5 h-1.5 rounded-full bg-[var(--status-warning)]" />
Paused
</div>
)}
</div>
</div>
<Handle
id="source"
type="source"
position={Position.Right}
isConnectable={true}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500',
data.status === 'completed' || data.status === 'verified'
? '!bg-[var(--status-success)]'
: '',
isDimmed && 'opacity-30'
)}
/>
</>
);
}
return (
<>
{/* Target handle (left side - receives dependencies) */}

View File

@@ -0,0 +1,7 @@
export const GRAPH_RENDER_MODE_FULL = 'full';
export const GRAPH_RENDER_MODE_COMPACT = 'compact';
export type GraphRenderMode = typeof GRAPH_RENDER_MODE_FULL | typeof GRAPH_RENDER_MODE_COMPACT;
export const GRAPH_LARGE_NODE_COUNT = 150;
export const GRAPH_LARGE_EDGE_COUNT = 300;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { useCallback, useState, useEffect, useMemo, useRef } from 'react';
import {
ReactFlow,
Background,
@@ -39,6 +39,12 @@ import { useDebounceValue } from 'usehooks-ts';
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
import {
GRAPH_LARGE_EDGE_COUNT,
GRAPH_LARGE_NODE_COUNT,
GRAPH_RENDER_MODE_COMPACT,
GRAPH_RENDER_MODE_FULL,
} from './constants';
// Define custom node and edge types - using any to avoid React Flow's strict typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -198,6 +204,17 @@ function GraphCanvasInner({
// Calculate filter results
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
const estimatedEdgeCount = useMemo(() => {
return features.reduce((total, feature) => {
const deps = feature.dependencies as string[] | undefined;
return total + (deps?.length ?? 0);
}, 0);
}, [features]);
const isLargeGraph =
features.length >= GRAPH_LARGE_NODE_COUNT || estimatedEdgeCount >= GRAPH_LARGE_EDGE_COUNT;
const renderMode = isLargeGraph ? GRAPH_RENDER_MODE_COMPACT : GRAPH_RENDER_MODE_FULL;
// Transform features to nodes and edges with filter results
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
features,
@@ -205,6 +222,8 @@ function GraphCanvasInner({
filterResult,
actionCallbacks: nodeActionCallbacks,
backgroundSettings,
renderMode,
enableEdgeAnimations: !isLargeGraph,
});
// Apply layout
@@ -457,6 +476,8 @@ function GraphCanvasInner({
}
}, []);
const shouldRenderVisibleOnly = isLargeGraph;
return (
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
<ReactFlow
@@ -478,6 +499,7 @@ function GraphCanvasInner({
maxZoom={2}
selectionMode={SelectionMode.Partial}
connectionMode={ConnectionMode.Loose}
onlyRenderVisibleElements={shouldRenderVisibleOnly}
proOptions={{ hideAttribution: true }}
className="graph-canvas"
>

View File

@@ -51,7 +51,7 @@ export function GraphView({
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: GraphViewProps) {
const { currentProject } = useAppStore();
const currentProject = useAppStore((state) => state.currentProject);
// Use the same background hook as the board view
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });

View File

@@ -54,16 +54,40 @@ function getAncestors(
/**
* Traverses down to find all descendants (features that depend on this one)
*/
function getDescendants(featureId: string, features: Feature[], visited: Set<string>): void {
function getDescendants(
featureId: string,
dependentsMap: Map<string, string[]>,
visited: Set<string>
): void {
if (visited.has(featureId)) return;
visited.add(featureId);
const dependents = dependentsMap.get(featureId);
if (!dependents || dependents.length === 0) return;
for (const dependentId of dependents) {
getDescendants(dependentId, dependentsMap, visited);
}
}
function buildDependentsMap(features: Feature[]): Map<string, string[]> {
const dependentsMap = new Map<string, string[]>();
for (const feature of features) {
const deps = feature.dependencies as string[] | undefined;
if (deps?.includes(featureId)) {
getDescendants(feature.id, features, visited);
if (!deps || deps.length === 0) continue;
for (const depId of deps) {
const existing = dependentsMap.get(depId);
if (existing) {
existing.push(feature.id);
} else {
dependentsMap.set(depId, [feature.id]);
}
}
}
return dependentsMap;
}
/**
@@ -91,9 +115,9 @@ function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[
* Gets the effective status of a feature (accounting for running state)
* Treats completed (archived) as verified
*/
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
function getEffectiveStatus(feature: Feature, runningTaskIds: Set<string>): StatusFilterValue {
if (feature.status === 'in_progress') {
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
return runningTaskIds.has(feature.id) ? 'running' : 'paused';
}
// Treat completed (archived) as verified
if (feature.status === 'completed') {
@@ -119,6 +143,7 @@ export function useGraphFilter(
).sort();
const normalizedQuery = searchQuery.toLowerCase().trim();
const runningTaskIds = new Set(runningAutoTasks);
const hasSearchQuery = normalizedQuery.length > 0;
const hasCategoryFilter = selectedCategories.length > 0;
const hasStatusFilter = selectedStatuses.length > 0;
@@ -139,6 +164,7 @@ export function useGraphFilter(
// Find directly matched nodes
const matchedNodeIds = new Set<string>();
const featureMap = new Map(features.map((f) => [f.id, f]));
const dependentsMap = buildDependentsMap(features);
for (const feature of features) {
let matchesSearch = true;
@@ -159,7 +185,7 @@ export function useGraphFilter(
// Check status match
if (hasStatusFilter) {
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
const effectiveStatus = getEffectiveStatus(feature, runningTaskIds);
matchesStatus = selectedStatuses.includes(effectiveStatus);
}
@@ -190,7 +216,7 @@ export function useGraphFilter(
getAncestors(id, featureMap, highlightedNodeIds);
// Add all descendants (dependents)
getDescendants(id, features, highlightedNodeIds);
getDescendants(id, dependentsMap, highlightedNodeIds);
}
// Get edges in the highlighted path

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { Node, Edge } from '@xyflow/react';
import { Feature } from '@/store/app-store';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createFeatureMap, getBlockingDependenciesFromMap } from '@automaker/dependency-resolver';
import { GRAPH_RENDER_MODE_FULL, type GraphRenderMode } from '../constants';
import { GraphFilterResult } from './use-graph-filter';
export interface TaskNodeData extends Feature {
@@ -31,6 +32,7 @@ export interface TaskNodeData extends Feature {
onResumeTask?: () => void;
onSpawnTask?: () => void;
onDeleteTask?: () => void;
renderMode?: GraphRenderMode;
}
export type TaskNode = Node<TaskNodeData, 'task'>;
@@ -40,6 +42,7 @@ export type DependencyEdge = Edge<{
isHighlighted?: boolean;
isDimmed?: boolean;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
renderMode?: GraphRenderMode;
}>;
export interface NodeActionCallbacks {
@@ -66,6 +69,8 @@ interface UseGraphNodesProps {
filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks;
backgroundSettings?: BackgroundSettings;
renderMode?: GraphRenderMode;
enableEdgeAnimations?: boolean;
}
/**
@@ -78,14 +83,14 @@ export function useGraphNodes({
filterResult,
actionCallbacks,
backgroundSettings,
renderMode = GRAPH_RENDER_MODE_FULL,
enableEdgeAnimations = true,
}: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
const edgeList: DependencyEdge[] = [];
const featureMap = new Map<string, Feature>();
// Create feature map for quick lookups
features.forEach((f) => featureMap.set(f.id, f));
const featureMap = createFeatureMap(features);
const runningTaskIds = new Set(runningAutoTasks);
// Extract filter state
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
@@ -95,8 +100,8 @@ export function useGraphNodes({
// Create nodes
features.forEach((feature) => {
const isRunning = runningAutoTasks.includes(feature.id);
const blockingDeps = getBlockingDependencies(feature, features);
const isRunning = runningTaskIds.has(feature.id);
const blockingDeps = getBlockingDependenciesFromMap(feature, featureMap);
// Calculate filter highlight states
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
@@ -121,6 +126,7 @@ export function useGraphNodes({
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
renderMode,
// Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id)
@@ -166,13 +172,14 @@ export function useGraphNodes({
source: depId,
target: feature.id,
type: 'dependency',
animated: isRunning || runningAutoTasks.includes(depId),
animated: enableEdgeAnimations && (isRunning || runningTaskIds.has(depId)),
data: {
sourceStatus: sourceFeature.status,
targetStatus: feature.status,
isHighlighted: edgeIsHighlighted,
isDimmed: edgeIsDimmed,
onDeleteDependency: actionCallbacks?.onDeleteDependency,
renderMode,
},
};
edgeList.push(edge);

View File

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

View File

@@ -1,123 +1,66 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Running Agents View
*
* Displays all currently running agents across all projects.
* Uses React Query for data fetching with automatic polling.
*/
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useNavigate } from '@tanstack/react-router';
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
const logger = createLogger('RunningAgentsView');
import { useRunningAgents } from '@/hooks/queries';
import { useStopFeature } from '@/hooks/mutations';
export function RunningAgentsView() {
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
const { setCurrentProject, projects } = useAppStore();
const navigate = useNavigate();
const fetchRunningAgents = useCallback(async () => {
try {
const api = getElectronAPI();
if (api.runningAgents) {
logger.debug('Fetching running agents list');
const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) {
logger.debug('Running agents list fetched', {
count: result.runningAgents.length,
});
setRunningAgents(result.runningAgents);
} else {
logger.debug('Running agents list fetch returned empty/failed', {
success: result.success,
});
}
} else {
logger.debug('Running agents API not available');
}
} catch (error) {
logger.error('Error fetching running agents:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
const logger = createLogger('RunningAgentsView');
// Initial fetch
useEffect(() => {
fetchRunningAgents();
}, [fetchRunningAgents]);
// Use React Query for running agents with auto-refresh
const { data, isLoading, isFetching, refetch } = useRunningAgents();
// Auto-refresh every 2 seconds
useEffect(() => {
const interval = setInterval(() => {
fetchRunningAgents();
}, 2000);
const runningAgents = data?.agents ?? [];
return () => clearInterval(interval);
}, [fetchRunningAgents]);
// Subscribe to auto-mode events to update in real-time
useEffect(() => {
const api = getElectronAPI();
if (!api.autoMode) {
logger.debug('Auto mode API not available for running agents view');
return;
}
const unsubscribe = api.autoMode.onEvent((event) => {
logger.debug('Auto mode event in running agents view', {
type: event.type,
});
// When a feature completes or errors, refresh the list
if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
fetchRunningAgents();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgents]);
// Use mutation for stopping features
const stopFeature = useStopFeature();
const handleRefresh = useCallback(() => {
logger.debug('Manual refresh requested for running agents');
setRefreshing(true);
fetchRunningAgents();
}, [fetchRunningAgents]);
refetch();
}, [refetch]);
const handleStopAgent = useCallback(
async (agent: RunningAgent) => {
try {
const api = getElectronAPI();
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
if (isBacklogPlan && api.backlogPlan) {
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
const api = getElectronAPI();
// Handle backlog plans separately - they use a different API
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
if (isBacklogPlan && api.backlogPlan) {
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
try {
await api.backlogPlan.stop();
fetchRunningAgents();
return;
} catch (error) {
logger.error('Failed to stop backlog plan', { featureId: agent.featureId, error });
} finally {
refetch();
}
if (api.autoMode) {
logger.debug('Stopping running agent', { featureId: agent.featureId });
await api.autoMode.stopFeature(agent.featureId);
// Refresh list after stopping
fetchRunningAgents();
} else {
logger.debug('Auto mode API not available to stop agent', { featureId: agent.featureId });
}
} catch (error) {
logger.error('Error stopping agent:', error);
return;
}
// Use mutation for regular features
stopFeature.mutate({ featureId: agent.featureId, projectPath: agent.projectPath });
},
[fetchRunningAgents]
[stopFeature, refetch, logger]
);
const handleNavigateToProject = useCallback(
(agent: RunningAgent) => {
// Find the project by path
const project = projects.find((p) => p.path === agent.projectPath);
if (project) {
logger.debug('Navigating to running agent project', {
@@ -144,7 +87,7 @@ export function RunningAgentsView() {
setSelectedAgent(agent);
}, []);
if (loading) {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<Spinner size="xl" />
@@ -169,8 +112,8 @@ export function RunningAgentsView() {
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
{refreshing ? (
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
{isFetching ? (
<Spinner size="sm" className="mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
@@ -258,7 +201,12 @@ export function RunningAgentsView() {
>
View Project
</Button>
<Button variant="destructive" size="sm" onClick={() => handleStopAgent(agent)}>
<Button
variant="destructive"
size="sm"
onClick={() => handleStopAgent(agent)}
disabled={stopFeature.isPending}
>
<Square className="h-3.5 w-3.5 mr-1.5" />
Stop
</Button>

View File

@@ -1,13 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store';
import { useClaudeUsage } from '@/hooks/queries';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, AlertCircle } from 'lucide-react';
const ERROR_NO_API = 'Claude usage API not available';
const CLAUDE_USAGE_TITLE = 'Claude Usage';
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
@@ -15,13 +13,10 @@ const CLAUDE_LOGIN_COMMAND = 'claude login';
const CLAUDE_NO_USAGE_MESSAGE =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
const REFRESH_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 2 * 60_000;
// Using purple/indigo for Claude branding
const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500';
@@ -81,77 +76,31 @@ function UsageCard({
export function ClaudeUsageSection() {
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const canFetchUsage = !!claudeAuthStatus?.authenticated;
// Use React Query for data fetching with automatic polling
const {
data: claudeUsage,
isLoading,
isFetching,
error,
dataUpdatedAt,
refetch,
} = useClaudeUsage(canFetchUsage);
// If we have usage data, we can show it even if auth status is unsure
const hasUsage = !!claudeUsage;
const lastUpdatedLabel = claudeUsageLastUpdated
? new Date(claudeUsageLastUpdated).toLocaleString()
: null;
const lastUpdatedLabel = useMemo(() => {
return dataUpdatedAt ? new Date(dataUpdatedAt).toLocaleString() : null;
}, [dataUpdatedAt]);
const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const showAuthWarning =
(!canFetchUsage && !hasUsage && !isLoading) ||
(error && error.includes('Authentication required'));
const isStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
const fetchUsage = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.claude) {
setError(ERROR_NO_API);
return;
}
const result = await api.claude.getUsage();
if ('error' in result) {
// Check for auth errors specifically
if (
result.message?.includes('Authentication required') ||
result.error?.includes('Authentication required')
) {
// We'll show the auth warning UI instead of a generic error
} else {
setError(result.message || result.error);
}
return;
}
setClaudeUsage(result);
} catch (fetchError) {
const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR;
setError(message);
} finally {
setIsLoading(false);
}
}, [setClaudeUsage]);
useEffect(() => {
// Initial fetch if authenticated and stale
// Compute staleness inside effect to avoid re-running when Date.now() changes
const isDataStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
if (canFetchUsage && isDataStale) {
void fetchUsage();
}
}, [fetchUsage, canFetchUsage, claudeUsageLastUpdated]);
useEffect(() => {
if (!canFetchUsage) return undefined;
const intervalId = setInterval(() => {
void fetchUsage();
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchUsage, canFetchUsage]);
(errorMessage && errorMessage.includes('Authentication required'));
return (
<div
@@ -173,13 +122,13 @@ export function ClaudeUsageSection() {
<Button
variant="ghost"
size="icon"
onClick={fetchUsage}
disabled={isLoading}
onClick={() => refetch()}
disabled={isFetching}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-claude-usage"
title={CLAUDE_REFRESH_LABEL}
>
{isLoading ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
{isFetching ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
@@ -195,10 +144,10 @@ export function ClaudeUsageSection() {
</div>
)}
{error && !showAuthWarning && (
{errorMessage && !showAuthWarning && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
<div className="text-sm text-red-400">{error}</div>
<div className="text-sm text-red-400">{errorMessage}</div>
</div>
)}
@@ -220,7 +169,7 @@ export function ClaudeUsageSection() {
</div>
)}
{!hasUsage && !error && !showAuthWarning && !isLoading && (
{!hasUsage && !errorMessage && !showAuthWarning && !isLoading && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{CLAUDE_NO_USAGE_MESSAGE}
</div>

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -35,10 +36,6 @@ function getAuthMethodLabel(method: string): string {
}
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
function ClaudeCliStatusSkeleton() {
return (
<div

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -30,10 +31,6 @@ function getAuthMethodLabel(method: string): string {
}
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
function CodexCliStatusSkeleton() {
return (
<div

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -20,10 +21,6 @@ interface CursorCliStatusProps {
onRefresh: () => void;
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
export function CursorCliStatusSkeleton() {
return (
<div

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -75,10 +76,6 @@ interface OpencodeCliStatusProps {
onRefresh: () => void;
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
export function OpencodeCliStatusSkeleton() {
return (
<div

View File

@@ -1,20 +1,17 @@
// @ts-nocheck
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, AlertCircle } from 'lucide-react';
import { OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import {
formatCodexPlanType,
formatCodexResetTime,
getCodexWindowLabel,
} from '@/lib/codex-usage-format';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore, type CodexRateLimitWindow } from '@/store/app-store';
import { useCodexUsage } from '@/hooks/queries';
import type { CodexRateLimitWindow } from '@/store/app-store';
const ERROR_NO_API = 'Codex usage API not available';
const CODEX_USAGE_TITLE = 'Codex Usage';
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.';
@@ -22,14 +19,11 @@ const CODEX_LOGIN_COMMAND = 'codex login';
const CODEX_NO_USAGE_MESSAGE =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
const PLAN_LABEL = 'Plan';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
const REFRESH_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 2 * 60_000;
const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500';
const USAGE_COLOR_OK = 'bg-emerald-500';
@@ -40,11 +34,12 @@ const isRateLimitWindow = (
export function CodexUsageSection() {
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const canFetchUsage = !!codexAuthStatus?.authenticated;
// Use React Query for data fetching with automatic polling
const { data: codexUsage, isLoading, isFetching, error, refetch } = useCodexUsage(canFetchUsage);
const rateLimits = codexUsage?.rateLimits ?? null;
const primary = rateLimits?.primary ?? null;
const secondary = rateLimits?.secondary ?? null;
@@ -55,46 +50,7 @@ export function CodexUsageSection() {
? new Date(codexUsage.lastUpdated).toLocaleString()
: null;
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS;
const fetchUsage = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.codex) {
setError(ERROR_NO_API);
return;
}
const result = await api.codex.getUsage();
if ('error' in result) {
setError(result.message || result.error);
return;
}
setCodexUsage(result);
} catch (fetchError) {
const message = fetchError instanceof Error ? fetchError.message : CODEX_FETCH_ERROR;
setError(message);
} finally {
setIsLoading(false);
}
}, [setCodexUsage]);
useEffect(() => {
if (canFetchUsage && isStale) {
void fetchUsage();
}
}, [fetchUsage, canFetchUsage, isStale]);
useEffect(() => {
if (!canFetchUsage) return undefined;
const intervalId = setInterval(() => {
void fetchUsage();
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchUsage, canFetchUsage]);
const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const getUsageColor = (percentage: number) => {
if (percentage >= WARNING_THRESHOLD) {
@@ -163,13 +119,13 @@ export function CodexUsageSection() {
<Button
variant="ghost"
size="icon"
onClick={fetchUsage}
disabled={isLoading}
onClick={() => refetch()}
disabled={isFetching}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-codex-usage"
title={CODEX_REFRESH_LABEL}
>
{isLoading ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
{isFetching ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
@@ -183,10 +139,10 @@ export function CodexUsageSection() {
</div>
</div>
)}
{error && (
{errorMessage && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
<div className="text-sm text-red-400">{error}</div>
<div className="text-sm text-red-400">{errorMessage}</div>
</div>
)}
{hasMetrics && (
@@ -211,7 +167,7 @@ export function CodexUsageSection() {
</div>
</div>
)}
{!hasMetrics && !error && canFetchUsage && !isLoading && (
{!hasMetrics && !errorMessage && canFetchUsage && !isLoading && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{CODEX_NO_USAGE_MESSAGE}
</div>

View File

@@ -1,103 +1,52 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
import { useState, useCallback, useEffect } from 'react';
import { useCursorPermissionsQuery, type CursorPermissionsData } from '@/hooks/queries';
import { useApplyCursorProfile, useCopyCursorConfig } from '@/hooks/mutations';
const logger = createLogger('CursorPermissions');
import { getHttpApiClient } from '@/lib/http-api-client';
import type { CursorPermissionProfile } from '@automaker/types';
export interface PermissionsData {
activeProfile: CursorPermissionProfile | null;
effectivePermissions: { allow: string[]; deny: string[] } | null;
hasProjectConfig: boolean;
availableProfiles: Array<{
id: string;
name: string;
description: string;
permissions: { allow: string[]; deny: string[] };
}>;
}
// Re-export for backward compatibility
export type PermissionsData = CursorPermissionsData;
/**
* Custom hook for managing Cursor CLI permissions
* Handles loading permissions data, applying profiles, and copying configs
*/
export function useCursorPermissions(projectPath?: string) {
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
const [isSavingPermissions, setIsSavingPermissions] = useState(false);
const [copiedConfig, setCopiedConfig] = useState(false);
// Load permissions data
const loadPermissions = useCallback(async () => {
setIsLoadingPermissions(true);
try {
const api = getHttpApiClient();
const result = await api.setup.getCursorPermissions(projectPath);
if (result.success) {
setPermissions({
activeProfile: result.activeProfile || null,
effectivePermissions: result.effectivePermissions || null,
hasProjectConfig: result.hasProjectConfig || false,
availableProfiles: result.availableProfiles || [],
});
}
} catch (error) {
logger.error('Failed to load Cursor permissions:', error);
} finally {
setIsLoadingPermissions(false);
}
}, [projectPath]);
// React Query hooks
const permissionsQuery = useCursorPermissionsQuery(projectPath);
const applyProfileMutation = useApplyCursorProfile(projectPath);
const copyConfigMutation = useCopyCursorConfig();
// Apply a permission profile
const applyProfile = useCallback(
async (profileId: 'strict' | 'development', scope: 'global' | 'project') => {
setIsSavingPermissions(true);
try {
const api = getHttpApiClient();
const result = await api.setup.applyCursorPermissionProfile(
profileId,
scope,
scope === 'project' ? projectPath : undefined
);
if (result.success) {
toast.success(result.message || `Applied ${profileId} profile`);
await loadPermissions();
} else {
toast.error(result.error || 'Failed to apply profile');
}
} catch (error) {
toast.error('Failed to apply profile');
} finally {
setIsSavingPermissions(false);
}
(profileId: 'strict' | 'development', scope: 'global' | 'project') => {
applyProfileMutation.mutate({ profileId, scope });
},
[projectPath, loadPermissions]
[applyProfileMutation]
);
// Copy example config to clipboard
const copyConfig = useCallback(async (profileId: 'strict' | 'development') => {
try {
const api = getHttpApiClient();
const result = await api.setup.getCursorExampleConfig(profileId);
const copyConfig = useCallback(
(profileId: 'strict' | 'development') => {
copyConfigMutation.mutate(profileId, {
onSuccess: () => {
setCopiedConfig(true);
setTimeout(() => setCopiedConfig(false), 2000);
},
});
},
[copyConfigMutation]
);
if (result.success && result.config) {
await navigator.clipboard.writeText(result.config);
setCopiedConfig(true);
toast.success('Config copied to clipboard');
setTimeout(() => setCopiedConfig(false), 2000);
}
} catch (error) {
toast.error('Failed to copy config');
}
}, []);
// Load permissions (refetch)
const loadPermissions = useCallback(() => {
permissionsQuery.refetch();
}, [permissionsQuery]);
return {
permissions,
isLoadingPermissions,
isSavingPermissions,
permissions: permissionsQuery.data ?? null,
isLoadingPermissions: permissionsQuery.isLoading,
isSavingPermissions: applyProfileMutation.isPending,
copiedConfig,
loadPermissions,
applyProfile,

View File

@@ -1,9 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
const logger = createLogger('CursorStatus');
import { getHttpApiClient } from '@/lib/http-api-client';
import { useEffect, useMemo, useCallback } from 'react';
import { useCursorCliStatus } from '@/hooks/queries';
import { useSetupStore } from '@/store/setup-store';
export interface CursorStatus {
@@ -15,52 +11,42 @@ export interface CursorStatus {
/**
* Custom hook for managing Cursor CLI status
* Handles checking CLI installation, authentication, and refresh functionality
* Uses React Query for data fetching with automatic caching.
*/
export function useCursorStatus() {
const { setCursorCliStatus } = useSetupStore();
const { data: result, isLoading, refetch } = useCursorCliStatus();
const [status, setStatus] = useState<CursorStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const api = getHttpApiClient();
const statusResult = await api.setup.getCursorStatus();
if (statusResult.success) {
const newStatus = {
installed: statusResult.installed ?? false,
version: statusResult.version ?? undefined,
authenticated: statusResult.auth?.authenticated ?? false,
method: statusResult.auth?.method,
};
setStatus(newStatus);
// Also update the global setup store so other components can access the status
setCursorCliStatus({
installed: newStatus.installed,
version: newStatus.version,
auth: newStatus.authenticated
? {
authenticated: true,
method: newStatus.method || 'unknown',
}
: undefined,
});
}
} catch (error) {
logger.error('Failed to load Cursor settings:', error);
toast.error('Failed to load Cursor settings');
} finally {
setIsLoading(false);
}
}, [setCursorCliStatus]);
// Transform the API result into the local CursorStatus shape
const status = useMemo((): CursorStatus | null => {
if (!result) return null;
return {
installed: result.installed ?? false,
version: result.version ?? undefined,
authenticated: result.auth?.authenticated ?? false,
method: result.auth?.method,
};
}, [result]);
// Keep the global setup store in sync with query data
useEffect(() => {
loadData();
}, [loadData]);
if (status) {
setCursorCliStatus({
installed: status.installed,
version: status.version,
auth: status.authenticated
? {
authenticated: true,
method: status.method || 'unknown',
}
: undefined,
});
}
}, [status, setCursorCliStatus]);
const loadData = useCallback(() => {
refetch();
}, [refetch]);
return {
status,

View File

@@ -5,59 +5,53 @@
* configuring which sources to load Skills from (user/project).
*/
import { useState } from 'react';
import { useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { useUpdateGlobalSettings } from '@/hooks/mutations';
export function useSkillsSettings() {
const enabled = useAppStore((state) => state.enableSkills);
const sources = useAppStore((state) => state.skillsSources);
const [isLoading, setIsLoading] = useState(false);
const updateEnabled = async (newEnabled: boolean) => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ enableSkills: newEnabled });
// Update local store after successful server update
useAppStore.setState({ enableSkills: newEnabled });
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
} catch (error) {
toast.error('Failed to update skills settings');
console.error(error);
} finally {
setIsLoading(false);
}
};
// React Query mutation (disable default toast)
const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
const updateSources = async (newSources: Array<'user' | 'project'>) => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ skillsSources: newSources });
// Update local store after successful server update
useAppStore.setState({ skillsSources: newSources });
toast.success('Skills sources updated');
} catch (error) {
toast.error('Failed to update skills sources');
console.error(error);
} finally {
setIsLoading(false);
}
};
const updateEnabled = useCallback(
(newEnabled: boolean) => {
updateSettingsMutation.mutate(
{ enableSkills: newEnabled },
{
onSuccess: () => {
useAppStore.setState({ enableSkills: newEnabled });
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
},
}
);
},
[updateSettingsMutation]
);
const updateSources = useCallback(
(newSources: Array<'user' | 'project'>) => {
updateSettingsMutation.mutate(
{ skillsSources: newSources },
{
onSuccess: () => {
useAppStore.setState({ skillsSources: newSources });
toast.success('Skills sources updated');
},
}
);
},
[updateSettingsMutation]
);
return {
enabled,
sources,
updateEnabled,
updateSources,
isLoading,
isLoading: updateSettingsMutation.isPending,
};
}

View File

@@ -5,59 +5,53 @@
* configuring which sources to load Subagents from (user/project).
*/
import { useState } from 'react';
import { useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { useUpdateGlobalSettings } from '@/hooks/mutations';
export function useSubagentsSettings() {
const enabled = useAppStore((state) => state.enableSubagents);
const sources = useAppStore((state) => state.subagentsSources);
const [isLoading, setIsLoading] = useState(false);
const updateEnabled = async (newEnabled: boolean) => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ enableSubagents: newEnabled });
// Update local store after successful server update
useAppStore.setState({ enableSubagents: newEnabled });
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
} catch (error) {
toast.error('Failed to update subagents settings');
console.error(error);
} finally {
setIsLoading(false);
}
};
// React Query mutation (disable default toast)
const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
const updateSources = async (newSources: Array<'user' | 'project'>) => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ subagentsSources: newSources });
// Update local store after successful server update
useAppStore.setState({ subagentsSources: newSources });
toast.success('Subagents sources updated');
} catch (error) {
toast.error('Failed to update subagents sources');
console.error(error);
} finally {
setIsLoading(false);
}
};
const updateEnabled = useCallback(
(newEnabled: boolean) => {
updateSettingsMutation.mutate(
{ enableSubagents: newEnabled },
{
onSuccess: () => {
useAppStore.setState({ enableSubagents: newEnabled });
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
},
}
);
},
[updateSettingsMutation]
);
const updateSources = useCallback(
(newSources: Array<'user' | 'project'>) => {
updateSettingsMutation.mutate(
{ subagentsSources: newSources },
{
onSuccess: () => {
useAppStore.setState({ subagentsSources: newSources });
toast.success('Subagents sources updated');
},
}
);
},
[updateSettingsMutation]
);
return {
enabled,
sources,
updateEnabled,
updateSources,
isLoading,
isLoading: updateSettingsMutation.isPending,
};
}

View File

@@ -9,10 +9,12 @@
* Agent definitions in settings JSON are used server-side only.
*/
import { useState, useEffect, useCallback } from 'react';
import { useMemo, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store';
import type { AgentDefinition } from '@automaker/types';
import { getElectronAPI } from '@/lib/electron';
import { useDiscoveredAgents } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
export type SubagentScope = 'global' | 'project';
export type SubagentType = 'filesystem';
@@ -35,51 +37,40 @@ interface FilesystemAgent {
}
export function useSubagents() {
const queryClient = useQueryClient();
const currentProject = useAppStore((state) => state.currentProject);
const [isLoading, setIsLoading] = useState(false);
const [subagentsWithScope, setSubagentsWithScope] = useState<SubagentWithScope[]>([]);
// Fetch filesystem agents
const fetchFilesystemAgents = useCallback(async () => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
console.warn('Settings API not available');
return;
}
const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']);
// Use React Query hook for fetching agents
const {
data: agents = [],
isLoading,
refetch,
} = useDiscoveredAgents(currentProject?.path, ['user', 'project']);
if (data.success && data.agents) {
// Transform filesystem agents to SubagentWithScope format
const agents: SubagentWithScope[] = data.agents.map(
({ name, definition, source, filePath }: FilesystemAgent) => ({
name,
definition,
scope: source === 'user' ? 'global' : 'project',
type: 'filesystem' as const,
source,
filePath,
})
);
setSubagentsWithScope(agents);
}
} catch (error) {
console.error('Failed to fetch filesystem agents:', error);
} finally {
setIsLoading(false);
}
}, [currentProject?.path]);
// Transform agents to SubagentWithScope format
const subagentsWithScope = useMemo((): SubagentWithScope[] => {
return agents.map(({ name, definition, source, filePath }: FilesystemAgent) => ({
name,
definition,
scope: source === 'user' ? 'global' : 'project',
type: 'filesystem' as const,
source,
filePath,
}));
}, [agents]);
// Fetch filesystem agents on mount and when project changes
useEffect(() => {
fetchFilesystemAgents();
}, [fetchFilesystemAgents]);
// Refresh function that invalidates the query cache
const refreshFilesystemAgents = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.settings.agents(currentProject?.path ?? ''),
});
await refetch();
}, [queryClient, currentProject?.path, refetch]);
return {
subagentsWithScope,
isLoading,
hasProject: !!currentProject,
refreshFilesystemAgents: fetchFilesystemAgents,
refreshFilesystemAgents,
};
}

View File

@@ -1,239 +1,79 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { ProviderToggle } from './provider-toggle';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types';
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
const logger = createLogger('OpencodeSettings');
const OPENCODE_PROVIDER_ID = 'opencode';
const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|';
const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([OPENCODE_PROVIDER_ID]);
export function OpencodeSettingsTab() {
const queryClient = useQueryClient();
const {
enabledOpencodeModels,
opencodeDefaultModel,
setOpencodeDefaultModel,
toggleOpencodeModel,
setDynamicOpencodeModels,
dynamicOpencodeModels,
enabledDynamicModelIds,
toggleDynamicModel,
cachedOpencodeProviders,
setCachedOpencodeProviders,
} = useAppStore();
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
const providerRefreshSignatureRef = useRef<string>('');
// Phase 1: Load CLI status quickly on mount
useEffect(() => {
const checkOpencodeStatus = async () => {
setIsCheckingOpencodeCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
setCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: result.auth.hasApiKey,
hasEnvApiKey: result.auth.hasEnvApiKey,
hasOAuthToken: result.auth.hasOAuthToken,
});
}
} else {
setCliStatus({
success: false,
status: 'not_installed',
recommendation: 'OpenCode CLI detection is only available in desktop mode.',
});
}
} catch (error) {
logger.error('Failed to check OpenCode CLI status:', error);
setCliStatus({
success: false,
status: 'not_installed',
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCheckingOpencodeCli(false);
}
// React Query hooks for data fetching
const {
data: cliStatusData,
isLoading: isCheckingOpencodeCli,
refetch: refetchCliStatus,
} = useOpencodeCliStatus();
const isCliInstalled = cliStatusData?.installed ?? false;
const { data: providersData = [], isFetching: isFetchingProviders } = useOpencodeProviders();
const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels();
// Transform CLI status to the expected format
const cliStatus = useMemo((): SharedCliStatus | null => {
if (!cliStatusData) return null;
return {
success: cliStatusData.success ?? false,
status: cliStatusData.installed ? 'installed' : 'not_installed',
method: cliStatusData.auth?.method,
version: cliStatusData.version,
path: cliStatusData.path,
recommendation: cliStatusData.recommendation,
installCommands: cliStatusData.installCommands,
};
checkOpencodeStatus();
}, []);
}, [cliStatusData]);
// Phase 2: Load dynamic models and providers in background (only if not cached)
useEffect(() => {
const loadDynamicContent = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup) return;
// Skip if already have cached data
const needsProviders = cachedOpencodeProviders.length === 0;
const needsModels = dynamicOpencodeModels.length === 0;
if (!needsProviders && !needsModels) return;
setIsLoadingDynamicModels(true);
try {
// Load providers if needed
if (needsProviders && api.setup.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Load models if needed
if (needsModels && api.setup.getOpencodeModels) {
const modelsResult = await api.setup.getOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
} catch (error) {
logger.error('Failed to load dynamic content:', error);
} finally {
setIsLoadingDynamicModels(false);
}
// Transform auth status to the expected format
const authStatus = useMemo((): OpencodeAuthStatus | null => {
if (!cliStatusData?.auth) return null;
return {
authenticated: cliStatusData.auth.authenticated,
method: (cliStatusData.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: cliStatusData.auth.hasApiKey,
hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
hasOAuthToken: cliStatusData.auth.hasOAuthToken,
error: cliStatusData.auth.error,
};
loadDynamicContent();
}, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const refreshModelsForNewProviders = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup?.refreshOpencodeModels) return;
if (isLoadingDynamicModels) return;
const authenticatedProviders = cachedOpencodeProviders
.filter((provider) => provider.authenticated)
.map((provider) => provider.id)
.filter((providerId) => !OPENCODE_STATIC_MODEL_PROVIDERS.has(providerId));
if (authenticatedProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const dynamicProviderIds = new Set(
dynamicOpencodeModels.map((model) => model.provider).filter(Boolean)
);
const missingProviders = authenticatedProviders.filter(
(providerId) => !dynamicProviderIds.has(providerId)
);
if (missingProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const signature = [...missingProviders].sort().join(OPENCODE_PROVIDER_SIGNATURE_SEPARATOR);
if (providerRefreshSignatureRef.current === signature) return;
providerRefreshSignatureRef.current = signature;
setIsLoadingDynamicModels(true);
try {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
} catch (error) {
logger.error('Failed to refresh OpenCode models for new providers:', error);
} finally {
setIsLoadingDynamicModels(false);
}
};
refreshModelsForNewProviders();
}, [
cachedOpencodeProviders,
dynamicOpencodeModels,
cliStatus?.success,
cliStatus?.status,
isLoadingDynamicModels,
setDynamicOpencodeModels,
]);
}, [cliStatusData]);
// Refresh all opencode-related queries
const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true);
setIsLoadingDynamicModels(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
setCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: result.auth.hasApiKey,
hasEnvApiKey: result.auth.hasEnvApiKey,
hasOAuthToken: result.auth.hasOAuthToken,
});
}
if (result.installed) {
// Refresh providers
if (api?.setup?.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Refresh dynamic models
if (api?.setup?.refreshOpencodeModels) {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
toast.success('OpenCode CLI refreshed');
}
}
} catch (error) {
logger.error('Failed to refresh OpenCode CLI status:', error);
toast.error('Failed to refresh OpenCode CLI status');
} finally {
setIsCheckingOpencodeCli(false);
setIsLoadingDynamicModels(false);
}
}, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.cli.opencode() }),
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencodeProviders() }),
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencode() }),
]);
await refetchCliStatus();
toast.success('OpenCode CLI refreshed');
}, [queryClient, refetchCliStatus]);
const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => {
@@ -241,7 +81,7 @@ export function OpencodeSettingsTab() {
try {
setOpencodeDefaultModel(model);
toast.success('Default model updated');
} catch (error) {
} catch {
toast.error('Failed to update default model');
} finally {
setIsSaving(false);
@@ -255,7 +95,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true);
try {
toggleOpencodeModel(model, enabled);
} catch (error) {
} catch {
toast.error('Failed to update models');
} finally {
setIsSaving(false);
@@ -269,7 +109,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true);
try {
toggleDynamicModel(modelId, enabled);
} catch (error) {
} catch {
toast.error('Failed to update dynamic model');
} finally {
setIsSaving(false);
@@ -287,7 +127,7 @@ export function OpencodeSettingsTab() {
);
}
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
const isLoadingDynamicModels = isFetchingProviders || isFetchingModels;
return (
<div className="space-y-6">
@@ -297,7 +137,7 @@ export function OpencodeSettingsTab() {
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
providers={providersData as OpenCodeProviderInfo[]}
isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli}
/>
@@ -310,8 +150,8 @@ export function OpencodeSettingsTab() {
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
dynamicModels={dynamicOpencodeModels}
providers={providersData as OpenCodeProviderInfo[]}
dynamicModels={modelsData}
enabledDynamicModelIds={enabledDynamicModelIds}
onDynamicModelToggle={handleDynamicModelToggle}
isLoadingDynamicModels={isLoadingDynamicModels}

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);
@@ -427,47 +433,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
logsRef.current = '';
setLogs('');
logger.debug('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
logger.error('[useSpecGeneration] Spec regeneration not available');
setIsCreating(false);
return;
}
const result = await api.specRegeneration.create(
currentProject.path,
projectOverview.trim(),
generateFeatures,
analyzeProjectOnCreate,
generateFeatures ? featureCountOnCreate : undefined
);
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start spec creation:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
createSpecMutation.mutate(
{
projectOverview: projectOverview.trim(),
generateFeatures,
analyzeProject: analyzeProjectOnCreate,
featureCount: generateFeatures ? featureCountOnCreate : undefined,
},
{
onError: (error) => {
const errorMsg = error.message;
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
},
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
);
}, [
currentProject,
projectOverview,
generateFeatures,
analyzeProjectOnCreate,
featureCountOnCreate,
createSpecMutation,
]);
const handleRegenerate = useCallback(async () => {
@@ -483,47 +476,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
'[useSpecGeneration] Starting spec regeneration, generateFeatures:',
generateFeaturesOnRegenerate
);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
logger.error('[useSpecGeneration] Spec regeneration not available');
setIsRegenerating(false);
return;
}
const result = await api.specRegeneration.generate(
currentProject.path,
projectDefinition.trim(),
generateFeaturesOnRegenerate,
analyzeProjectOnRegenerate,
generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined
);
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start regeneration:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
regenerateSpecMutation.mutate(
{
projectDefinition: projectDefinition.trim(),
generateFeatures: generateFeaturesOnRegenerate,
analyzeProject: analyzeProjectOnRegenerate,
featureCount: generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined,
},
{
onError: (error) => {
const errorMsg = error.message;
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
},
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
);
}, [
currentProject,
projectDefinition,
generateFeaturesOnRegenerate,
analyzeProjectOnRegenerate,
featureCountOnRegenerate,
regenerateSpecMutation,
]);
const handleGenerateFeatures = useCallback(async () => {
@@ -536,36 +516,20 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
logsRef.current = '';
setLogs('');
logger.debug('[useSpecGeneration] Starting feature generation from existing spec');
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
logger.error('[useSpecGeneration] Spec regeneration not available');
setIsGeneratingFeatures(false);
return;
}
const result = await api.specRegeneration.generateFeatures(currentProject.path);
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start feature generation:', errorMsg);
generateFeaturesMutation.mutate(undefined, {
onError: (error) => {
const errorMsg = error.message;
logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
setIsGeneratingFeatures(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`;
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
setIsGeneratingFeatures(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
}, [currentProject]);
},
});
}, [currentProject, generateFeaturesMutation]);
const handleSync = useCallback(async () => {
if (!currentProject) return;

View File

@@ -1,62 +1,51 @@
import { useEffect, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('SpecLoading');
import { getElectronAPI } from '@/lib/electron';
import { useSpecFile, useSpecRegenerationStatus } from '@/hooks/queries';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
export function useSpecLoading() {
const { currentProject, setAppSpec } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const queryClient = useQueryClient();
const [specExists, setSpecExists] = useState(true);
const [isGenerationRunning, setIsGenerationRunning] = useState(false);
const loadSpec = useCallback(async () => {
if (!currentProject) return;
// React Query hooks
const specFileQuery = useSpecFile(currentProject?.path);
const statusQuery = useSpecRegenerationStatus(currentProject?.path);
setIsLoading(true);
try {
const api = getElectronAPI();
// Check if spec generation is running
if (api.specRegeneration) {
const status = await api.specRegeneration.status(currentProject.path);
if (status.success && status.isRunning) {
logger.debug('Spec generation is running for this project');
setIsGenerationRunning(true);
} else {
setIsGenerationRunning(false);
}
} else {
setIsGenerationRunning(false);
}
// Always try to load the spec file, even if generation is running
// This allows users to view their existing spec while generating features
const result = await api.readFile(`${currentProject.path}/.automaker/app_spec.txt`);
if (result.success && result.content) {
setAppSpec(result.content);
setSpecExists(true);
} else {
// File doesn't exist
setAppSpec('');
setSpecExists(false);
}
} catch (error) {
logger.error('Failed to load spec:', error);
setSpecExists(false);
} finally {
setIsLoading(false);
}
}, [currentProject, setAppSpec]);
const isGenerationRunning = statusQuery.data?.isRunning ?? false;
// Update app store and specExists when spec file data changes
useEffect(() => {
loadSpec();
}, [loadSpec]);
if (specFileQuery.data && !isGenerationRunning) {
setAppSpec(specFileQuery.data.content);
setSpecExists(specFileQuery.data.exists);
}
}, [specFileQuery.data, setAppSpec, isGenerationRunning]);
// Manual reload function (invalidates cache)
const loadSpec = useCallback(async () => {
if (!currentProject?.path) return;
// Fetch fresh status data to avoid stale cache issues
// Using fetchQuery ensures we get the latest data before checking
const statusData = await queryClient.fetchQuery<{ isRunning: boolean }>({
queryKey: queryKeys.specRegeneration.status(currentProject.path),
staleTime: 0, // Force fresh fetch
});
if (statusData?.isRunning) {
return;
}
// Invalidate and refetch spec file
await queryClient.invalidateQueries({
queryKey: queryKeys.spec.file(currentProject.path),
});
}, [currentProject?.path, queryClient]);
return {
isLoading,
isLoading: specFileQuery.isLoading,
specExists,
setSpecExists,
isGenerationRunning,

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

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

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

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

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

View File

@@ -0,0 +1,91 @@
/**
* Query Hooks Barrel Export
*
* Central export point for all React Query hooks.
* Import from this file for cleaner imports across the app.
*
* @example
* ```tsx
* import { useFeatures, useGitHubIssues, useClaudeUsage } from '@/hooks/queries';
* ```
*/
// Features
export { useFeatures, useFeature, useAgentOutput } from './use-features';
// GitHub
export {
useGitHubIssues,
useGitHubPRs,
useGitHubValidations,
useGitHubRemote,
useGitHubIssueComments,
} from './use-github';
// Usage
export { useClaudeUsage, useCodexUsage } from './use-usage';
// Running Agents
export { useRunningAgents, useRunningAgentsCount } from './use-running-agents';
// Worktrees
export {
useWorktrees,
useWorktreeInfo,
useWorktreeStatus,
useWorktreeDiffs,
useWorktreeBranches,
useWorktreeInitScript,
useAvailableEditors,
} from './use-worktrees';
// Settings
export {
useGlobalSettings,
useProjectSettings,
useSettingsStatus,
useCredentials,
useDiscoveredAgents,
} from './use-settings';
// Models
export {
useAvailableModels,
useCodexModels,
useOpencodeModels,
useOpencodeProviders,
useModelProviders,
} from './use-models';
// CLI Status
export {
useClaudeCliStatus,
useCursorCliStatus,
useCodexCliStatus,
useOpencodeCliStatus,
useGitHubCliStatus,
useApiKeysStatus,
usePlatformInfo,
} from './use-cli-status';
// Ideation
export { useIdeationPrompts, useIdeas, useIdea } from './use-ideation';
// Sessions
export { useSessions, useSessionHistory, useSessionQueue } from './use-sessions';
// Git
export { useGitDiffs } from './use-git';
// Pipeline
export { usePipelineConfig } from './use-pipeline';
// Spec
export { useSpecFile, useSpecRegenerationStatus } from './use-spec';
// Cursor Permissions
export { useCursorPermissionsQuery } from './use-cursor-permissions';
export type { CursorPermissionsData } from './use-cursor-permissions';
// Workspace
export { useWorkspaceDirectories } from './use-workspace';

View File

@@ -0,0 +1,147 @@
/**
* CLI Status Query Hooks
*
* React Query hooks for fetching CLI tool status (Claude, Cursor, Codex, etc.)
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
/**
* Fetch Claude CLI status
*
* @returns Query result with Claude CLI status
*/
export function useClaudeCliStatus() {
return useQuery({
queryKey: queryKeys.cli.claude(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getClaudeStatus();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch Claude status');
}
return result;
},
staleTime: STALE_TIMES.CLI_STATUS,
});
}
/**
* Fetch Cursor CLI status
*
* @returns Query result with Cursor CLI status
*/
export function useCursorCliStatus() {
return useQuery({
queryKey: queryKeys.cli.cursor(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getCursorStatus();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch Cursor status');
}
return result;
},
staleTime: STALE_TIMES.CLI_STATUS,
});
}
/**
* Fetch Codex CLI status
*
* @returns Query result with Codex CLI status
*/
export function useCodexCliStatus() {
return useQuery({
queryKey: queryKeys.cli.codex(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getCodexStatus();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch Codex status');
}
return result;
},
staleTime: STALE_TIMES.CLI_STATUS,
});
}
/**
* Fetch OpenCode CLI status
*
* @returns Query result with OpenCode CLI status
*/
export function useOpencodeCliStatus() {
return useQuery({
queryKey: queryKeys.cli.opencode(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getOpencodeStatus();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch OpenCode status');
}
return result;
},
staleTime: STALE_TIMES.CLI_STATUS,
});
}
/**
* Fetch GitHub CLI status
*
* @returns Query result with GitHub CLI status
*/
export function useGitHubCliStatus() {
return useQuery({
queryKey: queryKeys.cli.github(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getGhStatus();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch GitHub CLI status');
}
return result;
},
staleTime: STALE_TIMES.CLI_STATUS,
});
}
/**
* Fetch API keys status
*
* @returns Query result with API keys status
*/
export function useApiKeysStatus() {
return useQuery({
queryKey: queryKeys.cli.apiKeys(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getApiKeys();
return result;
},
staleTime: STALE_TIMES.CLI_STATUS,
});
}
/**
* Fetch platform info
*
* @returns Query result with platform info
*/
export function usePlatformInfo() {
return useQuery({
queryKey: queryKeys.cli.platform(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getPlatform();
if (!result.success) {
throw new Error('Failed to fetch platform info');
}
return result;
},
staleTime: Infinity, // Platform info never changes
});
}

View File

@@ -0,0 +1,58 @@
/**
* Cursor Permissions Query Hooks
*
* React Query hooks for fetching Cursor CLI permissions.
*/
import { useQuery } from '@tanstack/react-query';
import { getHttpApiClient } from '@/lib/http-api-client';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { CursorPermissionProfile } from '@automaker/types';
export interface CursorPermissionsData {
activeProfile: CursorPermissionProfile | null;
effectivePermissions: { allow: string[]; deny: string[] } | null;
hasProjectConfig: boolean;
availableProfiles: Array<{
id: string;
name: string;
description: string;
permissions: { allow: string[]; deny: string[] };
}>;
}
/**
* Fetch Cursor permissions for a project
*
* @param projectPath - Optional path to the project
* @param enabled - Whether to enable the query
* @returns Query result with permissions data
*
* @example
* ```tsx
* const { data: permissions, isLoading, refetch } = useCursorPermissions(projectPath);
* ```
*/
export function useCursorPermissionsQuery(projectPath?: string, enabled = true) {
return useQuery({
queryKey: queryKeys.cursorPermissions.permissions(projectPath),
queryFn: async (): Promise<CursorPermissionsData> => {
const api = getHttpApiClient();
const result = await api.setup.getCursorPermissions(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to load permissions');
}
return {
activeProfile: result.activeProfile || null,
effectivePermissions: result.effectivePermissions || null,
hasProjectConfig: result.hasProjectConfig || false,
availableProfiles: result.availableProfiles || [],
};
},
enabled,
staleTime: STALE_TIMES.SETTINGS,
});
}

View File

@@ -0,0 +1,136 @@
/**
* Features Query Hooks
*
* React Query hooks for fetching and managing features data.
* These hooks replace manual useState/useEffect patterns with
* automatic caching, deduplication, and background refetching.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { Feature } from '@/store/app-store';
const FEATURES_REFETCH_ON_FOCUS = false;
const FEATURES_REFETCH_ON_RECONNECT = false;
/**
* Fetch all features for a project
*
* @param projectPath - Path to the project
* @returns Query result with features array
*
* @example
* ```tsx
* const { data: features, isLoading, error } = useFeatures(currentProject?.path);
* ```
*/
export function useFeatures(projectPath: string | undefined) {
return useQuery({
queryKey: queryKeys.features.all(projectPath ?? ''),
queryFn: async (): Promise<Feature[]> => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.features?.getAll(projectPath);
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch features');
}
return (result.features ?? []) as Feature[];
},
enabled: !!projectPath,
staleTime: STALE_TIMES.FEATURES,
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
});
}
interface UseFeatureOptions {
enabled?: boolean;
/** Override polling interval (ms). Use false to disable polling. */
pollingInterval?: number | false;
}
/**
* Fetch a single feature by ID
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature to fetch
* @param options - Query options including enabled and polling interval
* @returns Query result with single feature
*/
export function useFeature(
projectPath: string | undefined,
featureId: string | undefined,
options: UseFeatureOptions = {}
) {
const { enabled = true, pollingInterval } = options;
return useQuery({
queryKey: queryKeys.features.single(projectPath ?? '', featureId ?? ''),
queryFn: async (): Promise<Feature | null> => {
if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
const api = getElectronAPI();
const result = await api.features?.get(projectPath, featureId);
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch feature');
}
return (result.feature as Feature) ?? null;
},
enabled: !!projectPath && !!featureId && enabled,
staleTime: STALE_TIMES.FEATURES,
refetchInterval: pollingInterval,
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
});
}
interface UseAgentOutputOptions {
enabled?: boolean;
/** Override polling interval (ms). Use false to disable polling. */
pollingInterval?: number | false;
}
/**
* Fetch agent output for a feature
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature
* @param options - Query options including enabled and polling interval
* @returns Query result with agent output string
*/
export function useAgentOutput(
projectPath: string | undefined,
featureId: string | undefined,
options: UseAgentOutputOptions = {}
) {
const { enabled = true, pollingInterval } = options;
return useQuery({
queryKey: queryKeys.features.agentOutput(projectPath ?? '', featureId ?? ''),
queryFn: async (): Promise<string> => {
if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
const api = getElectronAPI();
const result = await api.features?.getAgentOutput(projectPath, featureId);
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch agent output');
}
return result.content ?? '';
},
enabled: !!projectPath && !!featureId && enabled,
staleTime: STALE_TIMES.AGENT_OUTPUT,
// Use provided polling interval or default behavior
refetchInterval:
pollingInterval !== undefined
? pollingInterval
: (query) => {
// Only poll if we have data and it's not empty (indicating active task)
if (query.state.data && query.state.data.length > 0) {
return 5000; // 5 seconds
}
return false;
},
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
});
}

View File

@@ -0,0 +1,37 @@
/**
* Git Query Hooks
*
* React Query hooks for git operations.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
/**
* Fetch git diffs for a project (main project, not worktree)
*
* @param projectPath - Path to the project
* @param enabled - Whether to enable the query
* @returns Query result with files and diff content
*/
export function useGitDiffs(projectPath: string | undefined, enabled = true) {
return useQuery({
queryKey: queryKeys.git.diffs(projectPath ?? ''),
queryFn: async () => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.git.getDiffs(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch diffs');
}
return {
files: result.files ?? [],
diff: result.diff ?? '',
};
},
enabled: !!projectPath && enabled,
staleTime: STALE_TIMES.WORKTREES,
});
}

View File

@@ -0,0 +1,184 @@
/**
* GitHub Query Hooks
*
* React Query hooks for fetching GitHub issues, PRs, and validations.
*/
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { GitHubIssue, GitHubPR, GitHubComment, IssueValidation } from '@/lib/electron';
interface GitHubIssuesResult {
openIssues: GitHubIssue[];
closedIssues: GitHubIssue[];
}
interface GitHubPRsResult {
openPRs: GitHubPR[];
mergedPRs: GitHubPR[];
}
/**
* Fetch GitHub issues for a project
*
* @param projectPath - Path to the project
* @returns Query result with open and closed issues
*
* @example
* ```tsx
* const { data, isLoading } = useGitHubIssues(currentProject?.path);
* const { openIssues, closedIssues } = data ?? { openIssues: [], closedIssues: [] };
* ```
*/
export function useGitHubIssues(projectPath: string | undefined) {
return useQuery({
queryKey: queryKeys.github.issues(projectPath ?? ''),
queryFn: async (): Promise<GitHubIssuesResult> => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.github.listIssues(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch issues');
}
return {
openIssues: result.openIssues ?? [],
closedIssues: result.closedIssues ?? [],
};
},
enabled: !!projectPath,
staleTime: STALE_TIMES.GITHUB,
});
}
/**
* Fetch GitHub PRs for a project
*
* @param projectPath - Path to the project
* @returns Query result with open and merged PRs
*/
export function useGitHubPRs(projectPath: string | undefined) {
return useQuery({
queryKey: queryKeys.github.prs(projectPath ?? ''),
queryFn: async (): Promise<GitHubPRsResult> => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.github.listPRs(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch PRs');
}
return {
openPRs: result.openPRs ?? [],
mergedPRs: result.mergedPRs ?? [],
};
},
enabled: !!projectPath,
staleTime: STALE_TIMES.GITHUB,
});
}
/**
* Fetch GitHub validations for a project
*
* @param projectPath - Path to the project
* @param issueNumber - Optional issue number to filter by
* @returns Query result with validations
*/
export function useGitHubValidations(projectPath: string | undefined, issueNumber?: number) {
return useQuery({
queryKey: issueNumber
? queryKeys.github.validation(projectPath ?? '', issueNumber)
: queryKeys.github.validations(projectPath ?? ''),
queryFn: async (): Promise<IssueValidation[]> => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.github.getValidations(projectPath, issueNumber);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch validations');
}
return result.validations ?? [];
},
enabled: !!projectPath,
staleTime: STALE_TIMES.GITHUB,
});
}
/**
* Check GitHub remote for a project
*
* @param projectPath - Path to the project
* @returns Query result with remote info
*/
export function useGitHubRemote(projectPath: string | undefined) {
return useQuery({
queryKey: queryKeys.github.remote(projectPath ?? ''),
queryFn: async () => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.github.checkRemote(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to check remote');
}
return {
hasRemote: result.hasRemote ?? false,
owner: result.owner,
repo: result.repo,
url: result.url,
};
},
enabled: !!projectPath,
staleTime: STALE_TIMES.GITHUB,
});
}
/**
* Fetch comments for a GitHub issue with pagination support
*
* Uses useInfiniteQuery for proper "load more" pagination.
*
* @param projectPath - Path to the project
* @param issueNumber - Issue number
* @returns Infinite query result with comments and pagination helpers
*
* @example
* ```tsx
* const {
* data,
* isLoading,
* isFetchingNextPage,
* hasNextPage,
* fetchNextPage,
* refetch,
* } = useGitHubIssueComments(projectPath, issueNumber);
*
* // Get all comments flattened
* const comments = data?.pages.flatMap(page => page.comments) ?? [];
* ```
*/
export function useGitHubIssueComments(
projectPath: string | undefined,
issueNumber: number | undefined
) {
return useInfiniteQuery({
queryKey: queryKeys.github.issueComments(projectPath ?? '', issueNumber ?? 0),
queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
if (!projectPath || !issueNumber) throw new Error('Missing project path or issue number');
const api = getElectronAPI();
const result = await api.github.getIssueComments(projectPath, issueNumber, pageParam);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch comments');
}
return {
comments: (result.comments ?? []) as GitHubComment[],
totalCount: result.totalCount ?? 0,
hasNextPage: result.hasNextPage ?? false,
endCursor: result.endCursor as string | undefined,
};
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.endCursor : undefined),
enabled: !!projectPath && !!issueNumber,
staleTime: STALE_TIMES.GITHUB,
});
}

View File

@@ -0,0 +1,86 @@
/**
* Ideation Query Hooks
*
* React Query hooks for fetching ideation prompts and ideas.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
/**
* Fetch ideation prompts
*
* @returns Query result with prompts and categories
*
* @example
* ```tsx
* const { data, isLoading, error } = useIdeationPrompts();
* const { prompts, categories } = data ?? { prompts: [], categories: [] };
* ```
*/
export function useIdeationPrompts() {
return useQuery({
queryKey: queryKeys.ideation.prompts(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.ideation?.getPrompts();
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch prompts');
}
return {
prompts: result.prompts ?? [],
categories: result.categories ?? [],
};
},
staleTime: STALE_TIMES.SETTINGS, // Prompts rarely change
});
}
/**
* Fetch ideas for a project
*
* @param projectPath - Path to the project
* @returns Query result with ideas array
*/
export function useIdeas(projectPath: string | undefined) {
return useQuery({
queryKey: queryKeys.ideation.ideas(projectPath ?? ''),
queryFn: async () => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.ideation?.listIdeas(projectPath);
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch ideas');
}
return result.ideas ?? [];
},
enabled: !!projectPath,
staleTime: STALE_TIMES.FEATURES,
});
}
/**
* Fetch a single idea by ID
*
* @param projectPath - Path to the project
* @param ideaId - ID of the idea
* @returns Query result with single idea
*/
export function useIdea(projectPath: string | undefined, ideaId: string | undefined) {
return useQuery({
queryKey: queryKeys.ideation.idea(projectPath ?? '', ideaId ?? ''),
queryFn: async () => {
if (!projectPath || !ideaId) throw new Error('Missing project path or idea ID');
const api = getElectronAPI();
const result = await api.ideation?.getIdea(projectPath, ideaId);
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch idea');
}
return result.idea;
},
enabled: !!projectPath && !!ideaId,
staleTime: STALE_TIMES.FEATURES,
});
}

View File

@@ -0,0 +1,134 @@
/**
* Models Query Hooks
*
* React Query hooks for fetching available AI models.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
interface CodexModel {
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}
interface OpencodeModel {
id: string;
name: string;
modelString: string;
provider: string;
description: string;
supportsTools: boolean;
supportsVision: boolean;
tier: string;
default?: boolean;
}
/**
* Fetch available models
*
* @returns Query result with available models
*/
export function useAvailableModels() {
return useQuery({
queryKey: queryKeys.models.available(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.model.getAvailable();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch available models');
}
return result.models ?? [];
},
staleTime: STALE_TIMES.MODELS,
});
}
/**
* Fetch Codex models
*
* @param refresh - Force refresh from server
* @returns Query result with Codex models
*/
export function useCodexModels(refresh = false) {
return useQuery({
queryKey: queryKeys.models.codex(),
queryFn: async (): Promise<CodexModel[]> => {
const api = getElectronAPI();
const result = await api.codex.getModels(refresh);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch Codex models');
}
return (result.models ?? []) as CodexModel[];
},
staleTime: STALE_TIMES.MODELS,
});
}
/**
* Fetch OpenCode models
*
* @param refresh - Force refresh from server
* @returns Query result with OpenCode models
*/
export function useOpencodeModels(refresh = false) {
return useQuery({
queryKey: queryKeys.models.opencode(),
queryFn: async (): Promise<OpencodeModel[]> => {
const api = getElectronAPI();
const result = await api.setup.getOpencodeModels(refresh);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch OpenCode models');
}
return (result.models ?? []) as OpencodeModel[];
},
staleTime: STALE_TIMES.MODELS,
});
}
/**
* Fetch OpenCode providers
*
* @returns Query result with OpenCode providers
*/
export function useOpencodeProviders() {
return useQuery({
queryKey: queryKeys.models.opencodeProviders(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getOpencodeProviders();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch OpenCode providers');
}
return result.providers ?? [];
},
staleTime: STALE_TIMES.MODELS,
});
}
/**
* Fetch model providers status
*
* @returns Query result with provider status
*/
export function useModelProviders() {
return useQuery({
queryKey: queryKeys.models.providers(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.model.checkProviders();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch providers');
}
return result.providers ?? {};
},
staleTime: STALE_TIMES.MODELS,
});
}

View File

@@ -0,0 +1,39 @@
/**
* Pipeline Query Hooks
*
* React Query hooks for fetching pipeline configuration.
*/
import { useQuery } from '@tanstack/react-query';
import { getHttpApiClient } from '@/lib/http-api-client';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { PipelineConfig } from '@/store/app-store';
/**
* Fetch pipeline config for a project
*
* @param projectPath - Path to the project
* @returns Query result with pipeline config
*
* @example
* ```tsx
* const { data: pipelineConfig, isLoading } = usePipelineConfig(currentProject?.path);
* ```
*/
export function usePipelineConfig(projectPath: string | undefined) {
return useQuery({
queryKey: queryKeys.pipeline.config(projectPath ?? ''),
queryFn: async (): Promise<PipelineConfig | null> => {
if (!projectPath) throw new Error('No project path');
const api = getHttpApiClient();
const result = await api.pipeline.getConfig(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch pipeline config');
}
return result.config ?? null;
},
enabled: !!projectPath,
staleTime: STALE_TIMES.SETTINGS,
});
}

View File

@@ -0,0 +1,66 @@
/**
* Running Agents Query Hook
*
* React Query hook for fetching currently running agents.
* This data is invalidated by WebSocket events when agents start/stop.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
const RUNNING_AGENTS_REFETCH_ON_FOCUS = false;
const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false;
interface RunningAgentsResult {
agents: RunningAgent[];
count: number;
}
/**
* Fetch all currently running agents
*
* @returns Query result with running agents and total count
*
* @example
* ```tsx
* const { data, isLoading } = useRunningAgents();
* const { agents, count } = data ?? { agents: [], count: 0 };
* ```
*/
export function useRunningAgents() {
return useQuery({
queryKey: queryKeys.runningAgents.all(),
queryFn: async (): Promise<RunningAgentsResult> => {
const api = getElectronAPI();
const result = await api.runningAgents.getAll();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch running agents');
}
return {
agents: result.runningAgents ?? [],
count: result.totalCount ?? 0,
};
},
staleTime: STALE_TIMES.RUNNING_AGENTS,
// Note: Don't use refetchInterval here - rely on WebSocket invalidation
// for real-time updates instead of polling
refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS,
refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT,
});
}
/**
* Get running agents count
* This is a selector that derives count from the main query
*
* @returns Query result with just the count
*/
export function useRunningAgentsCount() {
const query = useRunningAgents();
return {
...query,
data: query.data?.count ?? 0,
};
}

View File

@@ -0,0 +1,86 @@
/**
* Sessions Query Hooks
*
* React Query hooks for fetching session data.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { SessionListItem } from '@/types/electron';
/**
* Fetch all sessions
*
* @param includeArchived - Whether to include archived sessions
* @returns Query result with sessions array
*
* @example
* ```tsx
* const { data: sessions, isLoading } = useSessions(false);
* ```
*/
export function useSessions(includeArchived = false) {
return useQuery({
queryKey: queryKeys.sessions.all(includeArchived),
queryFn: async (): Promise<SessionListItem[]> => {
const api = getElectronAPI();
const result = await api.sessions.list(includeArchived);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch sessions');
}
return result.sessions ?? [];
},
staleTime: STALE_TIMES.SESSIONS,
});
}
/**
* Fetch session history
*
* @param sessionId - ID of the session
* @returns Query result with session messages
*/
export function useSessionHistory(sessionId: string | undefined) {
return useQuery({
queryKey: queryKeys.sessions.history(sessionId ?? ''),
queryFn: async () => {
if (!sessionId) throw new Error('No session ID');
const api = getElectronAPI();
const result = await api.agent.getHistory(sessionId);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch session history');
}
return {
messages: result.messages ?? [],
isRunning: result.isRunning ?? false,
};
},
enabled: !!sessionId,
staleTime: STALE_TIMES.FEATURES, // Session history changes during conversations
});
}
/**
* Fetch session message queue
*
* @param sessionId - ID of the session
* @returns Query result with queued messages
*/
export function useSessionQueue(sessionId: string | undefined) {
return useQuery({
queryKey: queryKeys.sessions.queue(sessionId ?? ''),
queryFn: async () => {
if (!sessionId) throw new Error('No session ID');
const api = getElectronAPI();
const result = await api.agent.queueList(sessionId);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch queue');
}
return result.queue ?? [];
},
enabled: !!sessionId,
staleTime: STALE_TIMES.RUNNING_AGENTS, // Queue changes frequently during use
});
}

View File

@@ -0,0 +1,123 @@
/**
* Settings Query Hooks
*
* React Query hooks for fetching global and project settings.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { GlobalSettings, ProjectSettings } from '@automaker/types';
/**
* Fetch global settings
*
* @returns Query result with global settings
*
* @example
* ```tsx
* const { data: settings, isLoading } = useGlobalSettings();
* ```
*/
export function useGlobalSettings() {
return useQuery({
queryKey: queryKeys.settings.global(),
queryFn: async (): Promise<GlobalSettings> => {
const api = getElectronAPI();
const result = await api.settings.getGlobal();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch global settings');
}
return result.settings as GlobalSettings;
},
staleTime: STALE_TIMES.SETTINGS,
});
}
/**
* Fetch project-specific settings
*
* @param projectPath - Path to the project
* @returns Query result with project settings
*/
export function useProjectSettings(projectPath: string | undefined) {
return useQuery({
queryKey: queryKeys.settings.project(projectPath ?? ''),
queryFn: async (): Promise<ProjectSettings> => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.settings.getProject(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch project settings');
}
return result.settings as ProjectSettings;
},
enabled: !!projectPath,
staleTime: STALE_TIMES.SETTINGS,
});
}
/**
* Fetch settings status (migration status, etc.)
*
* @returns Query result with settings status
*/
export function useSettingsStatus() {
return useQuery({
queryKey: queryKeys.settings.status(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.settings.getStatus();
return result;
},
staleTime: STALE_TIMES.SETTINGS,
});
}
/**
* Fetch credentials status (masked API keys)
*
* @returns Query result with credentials info
*/
export function useCredentials() {
return useQuery({
queryKey: queryKeys.settings.credentials(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.settings.getCredentials();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch credentials');
}
return result.credentials;
},
staleTime: STALE_TIMES.SETTINGS,
});
}
/**
* Discover agents for a project
*
* @param projectPath - Path to the project
* @param sources - Sources to search ('user' | 'project')
* @returns Query result with discovered agents
*/
export function useDiscoveredAgents(
projectPath: string | undefined,
sources?: Array<'user' | 'project'>
) {
return useQuery({
// Include sources in query key so different source combinations have separate caches
queryKey: queryKeys.settings.agents(projectPath ?? '', sources),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.settings.discoverAgents(projectPath, sources);
if (!result.success) {
throw new Error(result.error || 'Failed to discover agents');
}
return result.agents ?? [];
},
enabled: !!projectPath,
staleTime: STALE_TIMES.SETTINGS,
});
}

View File

@@ -0,0 +1,103 @@
/**
* Spec Query Hooks
*
* React Query hooks for fetching spec file content and regeneration status.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
interface SpecFileResult {
content: string;
exists: boolean;
}
interface SpecRegenerationStatusResult {
isRunning: boolean;
currentPhase?: string;
}
/**
* Fetch spec file content for a project
*
* @param projectPath - Path to the project
* @returns Query result with spec content and existence flag
*
* @example
* ```tsx
* const { data, isLoading } = useSpecFile(currentProject?.path);
* if (data?.exists) {
* console.log(data.content);
* }
* ```
*/
export function useSpecFile(projectPath: string | undefined) {
return useQuery({
queryKey: queryKeys.spec.file(projectPath ?? ''),
queryFn: async (): Promise<SpecFileResult> => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.readFile(`${projectPath}/.automaker/app_spec.txt`);
if (result.success && result.content) {
return {
content: result.content,
exists: true,
};
}
return {
content: '',
exists: false,
};
},
enabled: !!projectPath,
staleTime: STALE_TIMES.SETTINGS,
});
}
/**
* Check spec regeneration status for a project
*
* @param projectPath - Path to the project
* @param enabled - Whether to enable the query (useful during regeneration)
* @returns Query result with regeneration status
*
* @example
* ```tsx
* const { data } = useSpecRegenerationStatus(projectPath, isRegenerating);
* if (data?.isRunning) {
* // Show loading indicator
* }
* ```
*/
export function useSpecRegenerationStatus(projectPath: string | undefined, enabled = true) {
return useQuery({
queryKey: queryKeys.specRegeneration.status(projectPath ?? ''),
queryFn: async (): Promise<SpecRegenerationStatusResult> => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
if (!api.specRegeneration) {
return { isRunning: false };
}
const status = await api.specRegeneration.status(projectPath);
if (status.success) {
return {
isRunning: status.isRunning ?? false,
currentPhase: status.currentPhase,
};
}
return { isRunning: false };
},
enabled: !!projectPath && enabled,
staleTime: 5000, // Check every 5 seconds when active
refetchInterval: enabled ? 5000 : false,
});
}

View File

@@ -0,0 +1,83 @@
/**
* Usage Query Hooks
*
* React Query hooks for fetching Claude and Codex API usage data.
* These hooks include automatic polling for real-time usage updates.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { ClaudeUsage, CodexUsage } from '@/store/app-store';
/** Polling interval for usage data (60 seconds) */
const USAGE_POLLING_INTERVAL = 60 * 1000;
const USAGE_REFETCH_ON_FOCUS = false;
const USAGE_REFETCH_ON_RECONNECT = false;
/**
* Fetch Claude API usage data
*
* @param enabled - Whether the query should run (default: true)
* @returns Query result with Claude usage data
*
* @example
* ```tsx
* const { data: usage, isLoading } = useClaudeUsage(isPopoverOpen);
* ```
*/
export function useClaudeUsage(enabled = true) {
return useQuery({
queryKey: queryKeys.usage.claude(),
queryFn: async (): Promise<ClaudeUsage> => {
const api = getElectronAPI();
const result = await api.claude.getUsage();
// Check if result is an error response
if ('error' in result) {
throw new Error(result.message || result.error);
}
return result;
},
enabled,
staleTime: STALE_TIMES.USAGE,
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
// Keep previous data while refetching
placeholderData: (previousData) => previousData,
refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
});
}
/**
* Fetch Codex API usage data
*
* @param enabled - Whether the query should run (default: true)
* @returns Query result with Codex usage data
*
* @example
* ```tsx
* const { data: usage, isLoading } = useCodexUsage(isPopoverOpen);
* ```
*/
export function useCodexUsage(enabled = true) {
return useQuery({
queryKey: queryKeys.usage.codex(),
queryFn: async (): Promise<CodexUsage> => {
const api = getElectronAPI();
const result = await api.codex.getUsage();
// Check if result is an error response
if ('error' in result) {
throw new Error(result.message || result.error);
}
return result;
},
enabled,
staleTime: STALE_TIMES.USAGE,
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
// Keep previous data while refetching
placeholderData: (previousData) => previousData,
refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
});
}

View File

@@ -0,0 +1,42 @@
/**
* Workspace Query Hooks
*
* React Query hooks for workspace operations.
*/
import { useQuery } from '@tanstack/react-query';
import { getHttpApiClient } from '@/lib/http-api-client';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
interface WorkspaceDirectory {
name: string;
path: string;
}
/**
* Fetch workspace directories
*
* @param enabled - Whether to enable the query
* @returns Query result with directories
*
* @example
* ```tsx
* const { data: directories, isLoading, error } = useWorkspaceDirectories(open);
* ```
*/
export function useWorkspaceDirectories(enabled = true) {
return useQuery({
queryKey: queryKeys.workspace.directories(),
queryFn: async (): Promise<WorkspaceDirectory[]> => {
const api = getHttpApiClient();
const result = await api.workspace.getDirectories();
if (!result.success) {
throw new Error(result.error || 'Failed to load directories');
}
return result.directories ?? [];
},
enabled,
staleTime: STALE_TIMES.SETTINGS,
});
}

View File

@@ -0,0 +1,270 @@
/**
* Worktrees Query Hooks
*
* React Query hooks for fetching worktree data.
*/
import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
const WORKTREE_REFETCH_ON_FOCUS = false;
const WORKTREE_REFETCH_ON_RECONNECT = false;
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
featureId?: string;
linkedToBranch?: string;
}
interface RemovedWorktree {
path: string;
branch: string;
}
interface WorktreesResult {
worktrees: WorktreeInfo[];
removedWorktrees: RemovedWorktree[];
}
/**
* Fetch all worktrees for a project
*
* @param projectPath - Path to the project
* @param includeDetails - Whether to include detailed info (default: true)
* @returns Query result with worktrees array and removed worktrees
*
* @example
* ```tsx
* const { data, isLoading, refetch } = useWorktrees(currentProject?.path);
* const worktrees = data?.worktrees ?? [];
* ```
*/
export function useWorktrees(projectPath: string | undefined, includeDetails = true) {
return useQuery({
queryKey: queryKeys.worktrees.all(projectPath ?? ''),
queryFn: async (): Promise<WorktreesResult> => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.worktree.listAll(projectPath, includeDetails);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch worktrees');
}
return {
worktrees: result.worktrees ?? [],
removedWorktrees: result.removedWorktrees ?? [],
};
},
enabled: !!projectPath,
staleTime: STALE_TIMES.WORKTREES,
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
});
}
/**
* Fetch worktree info for a specific feature
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature
* @returns Query result with worktree info
*/
export function useWorktreeInfo(projectPath: string | undefined, featureId: string | undefined) {
return useQuery({
queryKey: queryKeys.worktrees.single(projectPath ?? '', featureId ?? ''),
queryFn: async () => {
if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
const api = getElectronAPI();
const result = await api.worktree.getInfo(projectPath, featureId);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch worktree info');
}
return result;
},
enabled: !!projectPath && !!featureId,
staleTime: STALE_TIMES.WORKTREES,
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
});
}
/**
* Fetch worktree status for a specific feature
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature
* @returns Query result with worktree status
*/
export function useWorktreeStatus(projectPath: string | undefined, featureId: string | undefined) {
return useQuery({
queryKey: queryKeys.worktrees.status(projectPath ?? '', featureId ?? ''),
queryFn: async () => {
if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
const api = getElectronAPI();
const result = await api.worktree.getStatus(projectPath, featureId);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch worktree status');
}
return result;
},
enabled: !!projectPath && !!featureId,
staleTime: STALE_TIMES.WORKTREES,
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
});
}
/**
* Fetch worktree diffs for a specific feature
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature
* @returns Query result with files and diff content
*/
export function useWorktreeDiffs(projectPath: string | undefined, featureId: string | undefined) {
return useQuery({
queryKey: queryKeys.worktrees.diffs(projectPath ?? '', featureId ?? ''),
queryFn: async () => {
if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
const api = getElectronAPI();
const result = await api.worktree.getDiffs(projectPath, featureId);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch diffs');
}
return {
files: result.files ?? [],
diff: result.diff ?? '',
};
},
enabled: !!projectPath && !!featureId,
staleTime: STALE_TIMES.WORKTREES,
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
});
}
interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote?: boolean;
lastCommit?: string;
upstream?: string;
}
interface BranchesResult {
branches: BranchInfo[];
aheadCount: number;
behindCount: number;
isGitRepo: boolean;
hasCommits: boolean;
}
/**
* Fetch available branches for a worktree
*
* @param worktreePath - Path to the worktree
* @param includeRemote - Whether to include remote branches
* @returns Query result with branches, ahead/behind counts, and git repo status
*/
export function useWorktreeBranches(worktreePath: string | undefined, includeRemote = false) {
return useQuery({
// Include includeRemote in query key so different configurations have separate caches
queryKey: queryKeys.worktrees.branches(worktreePath ?? '', includeRemote),
queryFn: async (): Promise<BranchesResult> => {
if (!worktreePath) throw new Error('No worktree path');
const api = getElectronAPI();
const result = await api.worktree.listBranches(worktreePath, includeRemote);
// Handle special git status codes
if (result.code === 'NOT_GIT_REPO') {
return {
branches: [],
aheadCount: 0,
behindCount: 0,
isGitRepo: false,
hasCommits: false,
};
}
if (result.code === 'NO_COMMITS') {
return {
branches: [],
aheadCount: 0,
behindCount: 0,
isGitRepo: true,
hasCommits: false,
};
}
if (!result.success) {
throw new Error(result.error || 'Failed to fetch branches');
}
return {
branches: result.result?.branches ?? [],
aheadCount: result.result?.aheadCount ?? 0,
behindCount: result.result?.behindCount ?? 0,
isGitRepo: true,
hasCommits: true,
};
},
enabled: !!worktreePath,
staleTime: STALE_TIMES.WORKTREES,
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
});
}
/**
* Fetch init script for a project
*
* @param projectPath - Path to the project
* @returns Query result with init script content
*/
export function useWorktreeInitScript(projectPath: string | undefined) {
return useQuery({
queryKey: queryKeys.worktrees.initScript(projectPath ?? ''),
queryFn: async () => {
if (!projectPath) throw new Error('No project path');
const api = getElectronAPI();
const result = await api.worktree.getInitScript(projectPath);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch init script');
}
return {
exists: result.exists ?? false,
content: result.content ?? '',
};
},
enabled: !!projectPath,
staleTime: STALE_TIMES.SETTINGS,
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
});
}
/**
* Fetch available editors
*
* @returns Query result with available editors
*/
export function useAvailableEditors() {
return useQuery({
queryKey: queryKeys.worktrees.editors(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.worktree.getAvailableEditors();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch editors');
}
return result.editors ?? [];
},
staleTime: STALE_TIMES.CLI_STATUS,
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
});
}

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);
@@ -25,118 +27,104 @@ export function useProjectSettingsLoader() {
);
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
const loadingRef = useRef<string | null>(null);
const currentProjectRef = useRef<string | null>(null);
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
// Fetch project settings with React Query
const { data: settings, dataUpdatedAt } = useProjectSettings(currentProject?.path);
// Apply settings when data changes
useEffect(() => {
currentProjectRef.current = currentProject?.path ?? null;
if (!currentProject?.path) {
if (!currentProject?.path || !settings) {
return;
}
// Prevent loading the same project multiple times
if (loadingRef.current === currentProject.path) {
// Prevent applying the same settings multiple times
if (
appliedProjectRef.current?.path === currentProject.path &&
appliedProjectRef.current?.dataUpdatedAt === dataUpdatedAt
) {
return;
}
loadingRef.current = currentProject.path;
const requestedProjectPath = currentProject.path;
appliedProjectRef.current = { path: currentProject.path, dataUpdatedAt };
const projectPath = currentProject.path;
const loadProjectSettings = async () => {
try {
const httpClient = getHttpApiClient();
const result = await httpClient.settings.getProject(requestedProjectPath);
const bg = settings.boardBackground;
// Race condition protection: ignore stale results if project changed
if (currentProjectRef.current !== requestedProjectPath) {
return;
}
// Apply boardBackground if present
if (bg?.imagePath) {
setBoardBackground(projectPath, bg.imagePath);
}
if (result.success && result.settings) {
const bg = result.settings.boardBackground;
// Settings map for cleaner iteration
const settingsMap = {
cardOpacity: setCardOpacity,
columnOpacity: setColumnOpacity,
columnBorderEnabled: setColumnBorderEnabled,
cardGlassmorphism: setCardGlassmorphism,
cardBorderEnabled: setCardBorderEnabled,
cardBorderOpacity: setCardBorderOpacity,
hideScrollbar: setHideScrollbar,
} as const;
// Apply boardBackground if present
if (bg?.imagePath) {
setBoardBackground(requestedProjectPath, bg.imagePath);
}
// Settings map for cleaner iteration
const settingsMap = {
cardOpacity: setCardOpacity,
columnOpacity: setColumnOpacity,
columnBorderEnabled: setColumnBorderEnabled,
cardGlassmorphism: setCardGlassmorphism,
cardBorderEnabled: setCardBorderEnabled,
cardBorderOpacity: setCardBorderOpacity,
hideScrollbar: setHideScrollbar,
} as const;
// Apply all settings that are defined
for (const [key, setter] of Object.entries(settingsMap)) {
const value = bg?.[key as keyof typeof bg];
if (value !== undefined) {
(setter as (path: string, val: typeof value) => void)(requestedProjectPath, value);
}
}
// Apply worktreePanelVisible if present
if (result.settings.worktreePanelVisible !== undefined) {
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
}
// Apply showInitScriptIndicator if present
if (result.settings.showInitScriptIndicator !== undefined) {
setShowInitScriptIndicator(
requestedProjectPath,
result.settings.showInitScriptIndicator
);
}
// Apply defaultDeleteBranch if present
if (result.settings.defaultDeleteBranchWithWorktree !== undefined) {
setDefaultDeleteBranch(
requestedProjectPath,
result.settings.defaultDeleteBranchWithWorktree
);
}
// Apply autoDismissInitScriptIndicator if present
if (result.settings.autoDismissInitScriptIndicator !== undefined) {
setAutoDismissInitScriptIndicator(
requestedProjectPath,
result.settings.autoDismissInitScriptIndicator
);
}
// Apply activeClaudeApiProfileId if present
// This is stored directly on the project, so we need to update the currentProject
// Type assertion needed because API returns Record<string, unknown>
const settingsWithProfile = result.settings as Record<string, unknown>;
const activeClaudeApiProfileId = settingsWithProfile.activeClaudeApiProfileId as
| string
| null
| undefined;
if (activeClaudeApiProfileId !== undefined) {
const updatedProject = useAppStore.getState().currentProject;
if (
updatedProject &&
updatedProject.path === requestedProjectPath &&
updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId
) {
setCurrentProject({
...updatedProject,
activeClaudeApiProfileId,
});
}
}
}
} catch (error) {
console.error('Failed to load project settings:', error);
// Don't show error toast - just log it
// Apply all settings that are defined
for (const [key, setter] of Object.entries(settingsMap)) {
const value = bg?.[key as keyof typeof bg];
if (value !== undefined) {
(setter as (path: string, val: typeof value) => void)(projectPath, value);
}
};
}
loadProjectSettings();
}, [currentProject?.path]);
// Apply worktreePanelVisible if present
if (settings.worktreePanelVisible !== undefined) {
setWorktreePanelVisible(projectPath, settings.worktreePanelVisible);
}
// Apply showInitScriptIndicator if present
if (settings.showInitScriptIndicator !== undefined) {
setShowInitScriptIndicator(projectPath, settings.showInitScriptIndicator);
}
// Apply defaultDeleteBranchWithWorktree if present
if (settings.defaultDeleteBranchWithWorktree !== undefined) {
setDefaultDeleteBranch(projectPath, settings.defaultDeleteBranchWithWorktree);
}
// Apply autoDismissInitScriptIndicator if present
if (settings.autoDismissInitScriptIndicator !== undefined) {
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
}
// Apply activeClaudeApiProfileId if present
if (settings.activeClaudeApiProfileId !== undefined) {
const updatedProject = useAppStore.getState().currentProject;
if (
updatedProject &&
updatedProject.path === projectPath &&
updatedProject.activeClaudeApiProfileId !== settings.activeClaudeApiProfileId
) {
setCurrentProject({
...updatedProject,
activeClaudeApiProfileId: settings.activeClaudeApiProfileId,
});
}
}
}, [
currentProject?.path,
settings,
dataUpdatedAt,
setBoardBackground,
setCardOpacity,
setColumnOpacity,
setColumnBorderEnabled,
setCardGlassmorphism,
setCardBorderEnabled,
setCardBorderOpacity,
setHideScrollbar,
setWorktreePanelVisible,
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setCurrentProject,
]);
}

View File

@@ -0,0 +1,234 @@
/**
* Query Invalidation Hooks
*
* These hooks connect WebSocket events to React Query cache invalidation,
* ensuring the UI stays in sync with server-side changes without manual refetching.
*/
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import type { AutoModeEvent, SpecRegenerationEvent } from '@/types/electron';
import type { IssueValidationEvent } from '@automaker/types';
/**
* Invalidate queries based on auto mode events
*
* This hook subscribes to auto mode events (feature start, complete, error, etc.)
* and invalidates relevant queries to keep the UI in sync.
*
* @param projectPath - Current project path
*
* @example
* ```tsx
* function BoardView() {
* const projectPath = useAppStore(s => s.currentProject?.path);
* useAutoModeQueryInvalidation(projectPath);
* // ...
* }
* ```
*/
export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
const queryClient = useQueryClient();
useEffect(() => {
if (!projectPath) return;
const api = getElectronAPI();
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
// Invalidate features when agent completes, errors, or receives plan approval
if (
event.type === 'auto_mode_feature_complete' ||
event.type === 'auto_mode_error' ||
event.type === 'plan_approval_required' ||
event.type === 'plan_approved' ||
event.type === 'plan_rejected' ||
event.type === 'pipeline_step_complete'
) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(projectPath),
});
}
// Invalidate running agents on any status change
if (
event.type === 'auto_mode_feature_start' ||
event.type === 'auto_mode_feature_complete' ||
event.type === 'auto_mode_error' ||
event.type === 'auto_mode_resuming_features'
) {
queryClient.invalidateQueries({
queryKey: queryKeys.runningAgents.all(),
});
}
// Invalidate specific feature when it starts or has phase changes
if (
(event.type === 'auto_mode_feature_start' ||
event.type === 'auto_mode_phase' ||
event.type === 'auto_mode_phase_complete' ||
event.type === 'pipeline_step_started') &&
'featureId' in event
) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.single(projectPath, event.featureId),
});
}
// Invalidate agent output during progress updates
if (event.type === 'auto_mode_progress' && 'featureId' in event) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.agentOutput(projectPath, event.featureId),
});
}
// Invalidate worktree queries when feature completes (may have created worktree)
if (event.type === 'auto_mode_feature_complete' && 'featureId' in event) {
queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPath),
});
queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.single(projectPath, event.featureId),
});
}
});
return unsubscribe;
}, [projectPath, queryClient]);
}
/**
* Invalidate queries based on spec regeneration events
*
* @param projectPath - Current project path
*/
export function useSpecRegenerationQueryInvalidation(projectPath: string | undefined) {
const queryClient = useQueryClient();
useEffect(() => {
if (!projectPath) return;
const api = getElectronAPI();
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
// Only handle events for the current project
if (event.projectPath !== projectPath) return;
if (event.type === 'spec_regeneration_complete') {
// Invalidate features as new ones may have been generated
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(projectPath),
});
// Invalidate spec regeneration status
queryClient.invalidateQueries({
queryKey: queryKeys.specRegeneration.status(projectPath),
});
}
});
return unsubscribe;
}, [projectPath, queryClient]);
}
/**
* Invalidate queries based on GitHub validation events
*
* @param projectPath - Current project path
*/
export function useGitHubValidationQueryInvalidation(projectPath: string | undefined) {
const queryClient = useQueryClient();
useEffect(() => {
if (!projectPath) return;
const api = getElectronAPI();
// Check if GitHub API is available before subscribing
if (!api.github?.onValidationEvent) {
return;
}
const unsubscribe = api.github.onValidationEvent((event: IssueValidationEvent) => {
if (event.type === 'validation_complete' || event.type === 'validation_error') {
// Invalidate all validations for this project
queryClient.invalidateQueries({
queryKey: queryKeys.github.validations(projectPath),
});
// Also invalidate specific issue validation if we have the issue number
if ('issueNumber' in event && event.issueNumber) {
queryClient.invalidateQueries({
queryKey: queryKeys.github.validation(projectPath, event.issueNumber),
});
}
}
});
return unsubscribe;
}, [projectPath, queryClient]);
}
/**
* Invalidate session queries based on agent stream events
*
* @param sessionId - Current session ID
*/
export function useSessionQueryInvalidation(sessionId: string | undefined) {
const queryClient = useQueryClient();
useEffect(() => {
if (!sessionId) return;
const api = getElectronAPI();
const unsubscribe = api.agent.onStream((event) => {
// Only handle events for the current session
if ('sessionId' in event && event.sessionId !== sessionId) return;
// Invalidate session history when a message is complete
if (event.type === 'complete' || event.type === 'message') {
queryClient.invalidateQueries({
queryKey: queryKeys.sessions.history(sessionId),
});
}
// Invalidate sessions list when any session changes
if (event.type === 'complete') {
queryClient.invalidateQueries({
queryKey: queryKeys.sessions.all(),
});
}
});
return unsubscribe;
}, [sessionId, queryClient]);
}
/**
* Combined hook that sets up all query invalidation subscriptions
*
* Use this hook at the app root or in a layout component to ensure
* all WebSocket events properly invalidate React Query caches.
*
* @param projectPath - Current project path
* @param sessionId - Current session ID (optional)
*
* @example
* ```tsx
* function AppLayout() {
* const projectPath = useAppStore(s => s.currentProject?.path);
* const sessionId = useAppStore(s => s.currentSessionId);
* useQueryInvalidation(projectPath, sessionId);
* // ...
* }
* ```
*/
export function useQueryInvalidation(
projectPath: string | undefined,
sessionId?: string | undefined
) {
useAutoModeQueryInvalidation(projectPath);
useSpecRegenerationQueryInvalidation(projectPath);
useGitHubValidationQueryInvalidation(projectPath);
useSessionQueryInvalidation(sessionId);
}

View File

@@ -730,8 +730,6 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
lastProjectDir: settings.lastProjectDir ?? '',
recentFolders: settings.recentFolders ?? [],
// Event hooks
eventHooks: settings.eventHooks ?? [],
// Terminal font (nested in terminalState)
...(settings.terminalFontFamily && {
terminalState: {
@@ -810,7 +808,6 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
lastProjectDir: state.lastProjectDir,
recentFolders: state.recentFolders,
terminalFontFamily: state.terminalState.fontFamily,
eventHooks: state.eventHooks,
};
}

View File

@@ -0,0 +1,138 @@
/**
* React Query Client Configuration
*
* Central configuration for TanStack React Query.
* Provides default options for queries and mutations including
* caching, retries, and error handling.
*/
import { QueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
import { isConnectionError, handleServerOffline } from './http-api-client';
const logger = createLogger('QueryClient');
/**
* Default stale times for different data types
*/
export const STALE_TIMES = {
/** Features change frequently during auto-mode */
FEATURES: 60 * 1000, // 1 minute
/** GitHub data is relatively stable */
GITHUB: 2 * 60 * 1000, // 2 minutes
/** Running agents state changes very frequently */
RUNNING_AGENTS: 5 * 1000, // 5 seconds
/** Agent output changes during streaming */
AGENT_OUTPUT: 5 * 1000, // 5 seconds
/** Usage data with polling */
USAGE: 30 * 1000, // 30 seconds
/** Models rarely change */
MODELS: 5 * 60 * 1000, // 5 minutes
/** CLI status rarely changes */
CLI_STATUS: 5 * 60 * 1000, // 5 minutes
/** Settings are relatively stable */
SETTINGS: 2 * 60 * 1000, // 2 minutes
/** Worktrees change during feature development */
WORKTREES: 30 * 1000, // 30 seconds
/** Sessions rarely change */
SESSIONS: 2 * 60 * 1000, // 2 minutes
/** Default for unspecified queries */
DEFAULT: 30 * 1000, // 30 seconds
} as const;
/**
* Default garbage collection times (gcTime, formerly cacheTime)
*/
export const GC_TIMES = {
/** Default garbage collection time */
DEFAULT: 5 * 60 * 1000, // 5 minutes
/** Extended for expensive queries */
EXTENDED: 10 * 60 * 1000, // 10 minutes
} as const;
/**
* Global error handler for queries
*/
const handleQueryError = (error: Error) => {
logger.error('Query error:', error);
// Check for connection errors (server offline)
if (isConnectionError(error)) {
handleServerOffline();
return;
}
// Don't toast for auth errors - those are handled by http-api-client
if (error.message === 'Unauthorized') {
return;
}
};
/**
* Global error handler for mutations
*/
const handleMutationError = (error: Error) => {
logger.error('Mutation error:', error);
// Check for connection errors
if (isConnectionError(error)) {
handleServerOffline();
return;
}
// Don't toast for auth errors
if (error.message === 'Unauthorized') {
return;
}
// Show error toast for other errors
toast.error('Operation failed', {
description: error.message || 'An unexpected error occurred',
});
};
/**
* Create and configure the QueryClient singleton
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: STALE_TIMES.DEFAULT,
gcTime: GC_TIMES.DEFAULT,
retry: (failureCount, error) => {
// Don't retry on auth errors
if (error instanceof Error && error.message === 'Unauthorized') {
return false;
}
// Don't retry on connection errors (server offline)
if (isConnectionError(error)) {
return false;
}
// Retry up to 2 times for other errors
return failureCount < 2;
},
refetchOnWindowFocus: true,
refetchOnReconnect: true,
// Don't refetch on mount if data is fresh
refetchOnMount: true,
},
mutations: {
onError: handleMutationError,
retry: false, // Don't auto-retry mutations
},
},
});
/**
* Set up global query error handling
* This catches errors that aren't handled by individual queries
*/
queryClient.getQueryCache().subscribe((event) => {
if (event.type === 'updated' && event.query.state.status === 'error') {
const error = event.query.state.error;
if (error instanceof Error) {
handleQueryError(error);
}
}
});

View File

@@ -0,0 +1,282 @@
/**
* Query Keys Factory
*
* Centralized query key definitions for React Query.
* Following the factory pattern for type-safe, consistent query keys.
*
* @see https://tkdodo.eu/blog/effective-react-query-keys
*/
/**
* Query keys for all API endpoints
*
* Structure follows the pattern:
* - ['entity'] for listing/global
* - ['entity', id] for single item
* - ['entity', id, 'sub-resource'] for nested resources
*/
export const queryKeys = {
// ============================================
// Features
// ============================================
features: {
/** All features for a project */
all: (projectPath: string) => ['features', projectPath] as const,
/** Single feature */
single: (projectPath: string, featureId: string) =>
['features', projectPath, featureId] as const,
/** Agent output for a feature */
agentOutput: (projectPath: string, featureId: string) =>
['features', projectPath, featureId, 'output'] as const,
},
// ============================================
// Worktrees
// ============================================
worktrees: {
/** All worktrees for a project */
all: (projectPath: string) => ['worktrees', projectPath] as const,
/** Single worktree info */
single: (projectPath: string, featureId: string) =>
['worktrees', projectPath, featureId] as const,
/** Branches for a worktree */
branches: (worktreePath: string, includeRemote = false) =>
['worktrees', 'branches', worktreePath, { includeRemote }] as const,
/** Worktree status */
status: (projectPath: string, featureId: string) =>
['worktrees', projectPath, featureId, 'status'] as const,
/** Worktree diffs */
diffs: (projectPath: string, featureId: string) =>
['worktrees', projectPath, featureId, 'diffs'] as const,
/** Init script for a project */
initScript: (projectPath: string) => ['worktrees', projectPath, 'init-script'] as const,
/** Available editors */
editors: () => ['worktrees', 'editors'] as const,
},
// ============================================
// GitHub
// ============================================
github: {
/** GitHub issues for a project */
issues: (projectPath: string) => ['github', 'issues', projectPath] as const,
/** GitHub PRs for a project */
prs: (projectPath: string) => ['github', 'prs', projectPath] as const,
/** GitHub validations for a project */
validations: (projectPath: string) => ['github', 'validations', projectPath] as const,
/** Single validation */
validation: (projectPath: string, issueNumber: number) =>
['github', 'validations', projectPath, issueNumber] as const,
/** Issue comments */
issueComments: (projectPath: string, issueNumber: number) =>
['github', 'issues', projectPath, issueNumber, 'comments'] as const,
/** Remote info */
remote: (projectPath: string) => ['github', 'remote', projectPath] as const,
},
// ============================================
// Settings
// ============================================
settings: {
/** Global settings */
global: () => ['settings', 'global'] as const,
/** Project-specific settings */
project: (projectPath: string) => ['settings', 'project', projectPath] as const,
/** Settings status */
status: () => ['settings', 'status'] as const,
/** Credentials (API keys) */
credentials: () => ['settings', 'credentials'] as const,
/** Discovered agents */
agents: (projectPath: string, sources?: Array<'user' | 'project'>) =>
['settings', 'agents', projectPath, sources ?? []] as const,
},
// ============================================
// Usage & Billing
// ============================================
usage: {
/** Claude API usage */
claude: () => ['usage', 'claude'] as const,
/** Codex API usage */
codex: () => ['usage', 'codex'] as const,
},
// ============================================
// Models
// ============================================
models: {
/** Available models */
available: () => ['models', 'available'] as const,
/** Codex models */
codex: () => ['models', 'codex'] as const,
/** OpenCode models */
opencode: () => ['models', 'opencode'] as const,
/** OpenCode providers */
opencodeProviders: () => ['models', 'opencode', 'providers'] as const,
/** Provider status */
providers: () => ['models', 'providers'] as const,
},
// ============================================
// Sessions
// ============================================
sessions: {
/** All sessions */
all: (includeArchived?: boolean) => ['sessions', { includeArchived }] as const,
/** Session history */
history: (sessionId: string) => ['sessions', sessionId, 'history'] as const,
/** Session queue */
queue: (sessionId: string) => ['sessions', sessionId, 'queue'] as const,
},
// ============================================
// Running Agents
// ============================================
runningAgents: {
/** All running agents */
all: () => ['runningAgents'] as const,
},
// ============================================
// Auto Mode
// ============================================
autoMode: {
/** Auto mode status */
status: (projectPath?: string) => ['autoMode', 'status', projectPath] as const,
/** Context exists check */
contextExists: (projectPath: string, featureId: string) =>
['autoMode', projectPath, featureId, 'context'] as const,
},
// ============================================
// Ideation
// ============================================
ideation: {
/** Ideation prompts */
prompts: () => ['ideation', 'prompts'] as const,
/** Ideas for a project */
ideas: (projectPath: string) => ['ideation', 'ideas', projectPath] as const,
/** Single idea */
idea: (projectPath: string, ideaId: string) =>
['ideation', 'ideas', projectPath, ideaId] as const,
/** Session */
session: (projectPath: string, sessionId: string) =>
['ideation', 'session', projectPath, sessionId] as const,
},
// ============================================
// CLI Status
// ============================================
cli: {
/** Claude CLI status */
claude: () => ['cli', 'claude'] as const,
/** Cursor CLI status */
cursor: () => ['cli', 'cursor'] as const,
/** Codex CLI status */
codex: () => ['cli', 'codex'] as const,
/** OpenCode CLI status */
opencode: () => ['cli', 'opencode'] as const,
/** GitHub CLI status */
github: () => ['cli', 'github'] as const,
/** API keys status */
apiKeys: () => ['cli', 'apiKeys'] as const,
/** Platform info */
platform: () => ['cli', 'platform'] as const,
},
// ============================================
// Cursor Permissions
// ============================================
cursorPermissions: {
/** Cursor permissions for a project */
permissions: (projectPath?: string) => ['cursorPermissions', projectPath] as const,
},
// ============================================
// Workspace
// ============================================
workspace: {
/** Workspace config */
config: () => ['workspace', 'config'] as const,
/** Workspace directories */
directories: () => ['workspace', 'directories'] as const,
},
// ============================================
// MCP (Model Context Protocol)
// ============================================
mcp: {
/** MCP server tools */
tools: (serverId: string) => ['mcp', 'tools', serverId] as const,
},
// ============================================
// Pipeline
// ============================================
pipeline: {
/** Pipeline config for a project */
config: (projectPath: string) => ['pipeline', projectPath] as const,
},
// ============================================
// Suggestions
// ============================================
suggestions: {
/** Suggestions status */
status: () => ['suggestions', 'status'] as const,
},
// ============================================
// Spec Regeneration
// ============================================
specRegeneration: {
/** Spec regeneration status */
status: (projectPath?: string) => ['specRegeneration', 'status', projectPath] as const,
},
// ============================================
// Spec
// ============================================
spec: {
/** Spec file content */
file: (projectPath: string) => ['spec', 'file', projectPath] as const,
},
// ============================================
// Context
// ============================================
context: {
/** File description */
file: (filePath: string) => ['context', 'file', filePath] as const,
/** Image description */
image: (imagePath: string) => ['context', 'image', imagePath] as const,
},
// ============================================
// File System
// ============================================
fs: {
/** Directory listing */
readdir: (dirPath: string) => ['fs', 'readdir', dirPath] as const,
/** File existence */
exists: (filePath: string) => ['fs', 'exists', filePath] as const,
/** File stats */
stat: (filePath: string) => ['fs', 'stat', filePath] as const,
},
// ============================================
// Git
// ============================================
git: {
/** Git diffs for a project */
diffs: (projectPath: string) => ['git', 'diffs', projectPath] as const,
/** File diff */
fileDiff: (projectPath: string, filePath: string) =>
['git', 'diffs', projectPath, filePath] as const,
},
} as const;
/**
* Type helper to extract query key types
*/
export type QueryKeys = typeof queryKeys;

View File

@@ -1,5 +1,7 @@
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { createLogger } from '@automaker/utils/logger';
import { Sidebar } from '@/components/layout/sidebar';
import { ProjectSwitcher } from '@/components/layout/project-switcher';
@@ -27,6 +29,7 @@ import {
signalMigrationComplete,
performSettingsMigration,
} from '@/hooks/use-settings-migration';
import { queryClient } from '@/lib/query-client';
import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
@@ -37,6 +40,7 @@ import { useIsCompact } from '@/hooks/use-media-query';
import type { Project } from '@/lib/electron';
const logger = createLogger('RootLayout');
const SHOW_QUERY_DEVTOOLS = import.meta.env.DEV;
const SERVER_READY_MAX_ATTEMPTS = 8;
const SERVER_READY_BACKOFF_BASE_MS = 250;
const SERVER_READY_MAX_DELAY_MS = 1500;
@@ -892,9 +896,14 @@ function RootLayoutContent() {
function RootLayout() {
return (
<FileBrowserProvider>
<RootLayoutContent />
</FileBrowserProvider>
<QueryClientProvider client={queryClient}>
<FileBrowserProvider>
<RootLayoutContent />
</FileBrowserProvider>
{SHOW_QUERY_DEVTOOLS ? (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
) : null}
</QueryClientProvider>
);
}

View File

@@ -132,6 +132,7 @@
:root {
/* Default to light mode */
--radius: 0.625rem;
--perf-contain-intrinsic-size: 500px;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -1120,3 +1121,9 @@
animation: none;
}
}
.perf-contain {
contain: layout paint;
content-visibility: auto;
contain-intrinsic-size: auto var(--perf-contain-intrinsic-size);
}

View File

@@ -7,6 +7,8 @@ export {
resolveDependencies,
areDependenciesSatisfied,
getBlockingDependencies,
createFeatureMap,
getBlockingDependenciesFromMap,
wouldCreateCircularDependency,
dependencyExists,
getAncestors,

View File

@@ -229,6 +229,49 @@ export function getBlockingDependencies(feature: Feature, allFeatures: Feature[]
});
}
/**
* Builds a lookup map for features by id.
*
* @param features - Features to index
* @returns Map keyed by feature id
*/
export function createFeatureMap(features: Feature[]): Map<string, Feature> {
const featureMap = new Map<string, Feature>();
for (const feature of features) {
if (feature?.id) {
featureMap.set(feature.id, feature);
}
}
return featureMap;
}
/**
* Gets the blocking dependencies using a precomputed feature map.
*
* @param feature - Feature to check
* @param featureMap - Map of all features by id
* @returns Array of feature IDs that are blocking this feature
*/
export function getBlockingDependenciesFromMap(
feature: Feature,
featureMap: Map<string, Feature>
): string[] {
const dependencies = feature.dependencies;
if (!dependencies || dependencies.length === 0) {
return [];
}
const blockingDependencies: string[] = [];
for (const depId of dependencies) {
const dep = featureMap.get(depId);
if (dep && dep.status !== 'completed' && dep.status !== 'verified') {
blockingDependencies.push(depId);
}
}
return blockingDependencies;
}
/**
* Checks if adding a dependency from sourceId to targetId would create a circular dependency.
* When we say "targetId depends on sourceId", we add sourceId to targetId.dependencies.

View File

@@ -3,6 +3,8 @@ import {
resolveDependencies,
areDependenciesSatisfied,
getBlockingDependencies,
createFeatureMap,
getBlockingDependenciesFromMap,
wouldCreateCircularDependency,
dependencyExists,
} from '../src/resolver';
@@ -351,6 +353,21 @@ describe('resolver.ts', () => {
});
});
describe('getBlockingDependenciesFromMap', () => {
it('should match getBlockingDependencies when using a feature map', () => {
const dep1 = createFeature('Dep1', { status: 'pending' });
const dep2 = createFeature('Dep2', { status: 'completed' });
const dep3 = createFeature('Dep3', { status: 'running' });
const feature = createFeature('A', { dependencies: ['Dep1', 'Dep2', 'Dep3'] });
const allFeatures = [dep1, dep2, dep3, feature];
const featureMap = createFeatureMap(allFeatures);
expect(getBlockingDependenciesFromMap(feature, featureMap)).toEqual(
getBlockingDependencies(feature, allFeatures)
);
});
});
describe('wouldCreateCircularDependency', () => {
it('should return false for features with no existing dependencies', () => {
const features = [createFeature('A'), createFeature('B')];

44
package-lock.json generated
View File

@@ -128,7 +128,8 @@
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "1.2.8",
"@tanstack/react-query": "5.90.12",
"@tanstack/react-query": "^5.90.17",
"@tanstack/react-query-devtools": "^5.91.2",
"@tanstack/react-router": "1.141.6",
"@uiw/react-codemirror": "4.25.4",
"@xterm/addon-fit": "0.10.0",
@@ -5594,9 +5595,19 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
"version": "5.90.19",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz",
"integrity": "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.92.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz",
"integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5604,12 +5615,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
"version": "5.90.19",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz",
"integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
"@tanstack/query-core": "5.90.19"
},
"funding": {
"type": "github",
@@ -5619,6 +5630,23 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz",
"integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.92.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.90.14",
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-router": {
"version": "1.141.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",