mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-29 22:02:02 +00:00
Merge pull request #499 from AutoMaker-Org/feat/react-query
feat(ui): migrate to React Query for data fetching
This commit is contained in:
8
TODO.md
8
TODO.md
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
18
apps/ui/src/components/ui/skeleton.tsx
Normal file
18
apps/ui/src/components/ui/skeleton.tsx
Normal 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)} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ?? '' });
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
7
apps/ui/src/components/views/graph-view/constants.ts
Normal file
7
apps/ui/src/components/views/graph-view/constants.ts
Normal 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;
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
79
apps/ui/src/hooks/mutations/index.ts
Normal file
79
apps/ui/src/hooks/mutations/index.ts
Normal 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';
|
||||
388
apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts
Normal file
388
apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
267
apps/ui/src/hooks/mutations/use-feature-mutations.ts
Normal file
267
apps/ui/src/hooks/mutations/use-feature-mutations.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
163
apps/ui/src/hooks/mutations/use-github-mutations.ts
Normal file
163
apps/ui/src/hooks/mutations/use-github-mutations.ts
Normal 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 ?? [];
|
||||
},
|
||||
});
|
||||
}
|
||||
82
apps/ui/src/hooks/mutations/use-ideation-mutations.ts
Normal file
82
apps/ui/src/hooks/mutations/use-ideation-mutations.ts
Normal 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
|
||||
});
|
||||
}
|
||||
144
apps/ui/src/hooks/mutations/use-settings-mutations.ts
Normal file
144
apps/ui/src/hooks/mutations/use-settings-mutations.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
184
apps/ui/src/hooks/mutations/use-spec-mutations.ts
Normal file
184
apps/ui/src/hooks/mutations/use-spec-mutations.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
480
apps/ui/src/hooks/mutations/use-worktree-mutations.ts
Normal file
480
apps/ui/src/hooks/mutations/use-worktree-mutations.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
91
apps/ui/src/hooks/queries/index.ts
Normal file
91
apps/ui/src/hooks/queries/index.ts
Normal 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';
|
||||
147
apps/ui/src/hooks/queries/use-cli-status.ts
Normal file
147
apps/ui/src/hooks/queries/use-cli-status.ts
Normal 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
|
||||
});
|
||||
}
|
||||
58
apps/ui/src/hooks/queries/use-cursor-permissions.ts
Normal file
58
apps/ui/src/hooks/queries/use-cursor-permissions.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
136
apps/ui/src/hooks/queries/use-features.ts
Normal file
136
apps/ui/src/hooks/queries/use-features.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
37
apps/ui/src/hooks/queries/use-git.ts
Normal file
37
apps/ui/src/hooks/queries/use-git.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
184
apps/ui/src/hooks/queries/use-github.ts
Normal file
184
apps/ui/src/hooks/queries/use-github.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
86
apps/ui/src/hooks/queries/use-ideation.ts
Normal file
86
apps/ui/src/hooks/queries/use-ideation.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
134
apps/ui/src/hooks/queries/use-models.ts
Normal file
134
apps/ui/src/hooks/queries/use-models.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
39
apps/ui/src/hooks/queries/use-pipeline.ts
Normal file
39
apps/ui/src/hooks/queries/use-pipeline.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
66
apps/ui/src/hooks/queries/use-running-agents.ts
Normal file
66
apps/ui/src/hooks/queries/use-running-agents.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
86
apps/ui/src/hooks/queries/use-sessions.ts
Normal file
86
apps/ui/src/hooks/queries/use-sessions.ts
Normal 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
|
||||
});
|
||||
}
|
||||
123
apps/ui/src/hooks/queries/use-settings.ts
Normal file
123
apps/ui/src/hooks/queries/use-settings.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
103
apps/ui/src/hooks/queries/use-spec.ts
Normal file
103
apps/ui/src/hooks/queries/use-spec.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
83
apps/ui/src/hooks/queries/use-usage.ts
Normal file
83
apps/ui/src/hooks/queries/use-usage.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
42
apps/ui/src/hooks/queries/use-workspace.ts
Normal file
42
apps/ui/src/hooks/queries/use-workspace.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
270
apps/ui/src/hooks/queries/use-worktrees.ts
Normal file
270
apps/ui/src/hooks/queries/use-worktrees.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
234
apps/ui/src/hooks/use-query-invalidation.ts
Normal file
234
apps/ui/src/hooks/use-query-invalidation.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
138
apps/ui/src/lib/query-client.ts
Normal file
138
apps/ui/src/lib/query-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
282
apps/ui/src/lib/query-keys.ts
Normal file
282
apps/ui/src/lib/query-keys.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export {
|
||||
resolveDependencies,
|
||||
areDependenciesSatisfied,
|
||||
getBlockingDependencies,
|
||||
createFeatureMap,
|
||||
getBlockingDependenciesFromMap,
|
||||
wouldCreateCircularDependency,
|
||||
dependencyExists,
|
||||
getAncestors,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
44
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user