mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +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.
|
- 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
|
# UX
|
||||||
|
|
||||||
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
|
- 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-switch": "1.2.6",
|
||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@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",
|
"@tanstack/react-router": "1.141.6",
|
||||||
"@uiw/react-codemirror": "4.25.4",
|
"@uiw/react-codemirror": "4.25.4",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { useClaudeUsage } from '@/hooks/queries';
|
||||||
// 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;
|
|
||||||
|
|
||||||
export function ClaudeUsagePopover() {
|
export function ClaudeUsagePopover() {
|
||||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<UsageError | null>(null);
|
|
||||||
|
|
||||||
// Check if CLI is verified/authenticated
|
// Check if CLI is verified/authenticated
|
||||||
const isCliVerified =
|
const isCliVerified =
|
||||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
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(() => {
|
const isStale = useMemo(() => {
|
||||||
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
|
||||||
}, [claudeUsageLastUpdated]);
|
}, [dataUpdatedAt]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// Derived status color/icon helper
|
// Derived status color/icon helper
|
||||||
const getStatusInfo = (percentage: number) => {
|
const getStatusInfo = (percentage: number) => {
|
||||||
@@ -144,7 +69,6 @@ export function ClaudeUsagePopover() {
|
|||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
stale?: boolean;
|
stale?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
// Check if percentage is valid (not NaN, not undefined, is a finite number)
|
|
||||||
const isValidPercentage =
|
const isValidPercentage =
|
||||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||||
const safePercentage = isValidPercentage ? percentage : 0;
|
const safePercentage = isValidPercentage ? percentage : 0;
|
||||||
@@ -245,10 +169,10 @@ export function ClaudeUsagePopover() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('h-6 w-6', loading && 'opacity-80')}
|
className={cn('h-6 w-6', isFetching && 'opacity-80')}
|
||||||
onClick={() => !loading && fetchUsage(false)}
|
onClick={() => !isFetching && refetch()}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -259,26 +183,16 @@ export function ClaudeUsagePopover() {
|
|||||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
|
<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" />
|
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||||
<div className="space-y-1 flex flex-col items-center">
|
<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">
|
<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{' '}
|
Make sure Claude CLI is installed and authenticated via{' '}
|
||||||
<code className="font-mono bg-muted px-1 rounded">claude login</code>
|
<code className="font-mono bg-muted px-1 rounded">claude login</code>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : !claudeUsage ? (
|
) : isLoading || !claudeUsage ? (
|
||||||
// Loading state
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||||
<Spinner size="lg" />
|
<Spinner size="lg" />
|
||||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
<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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { useCodexUsage } from '@/hooks/queries';
|
||||||
|
|
||||||
// Error codes for distinguishing failure modes
|
// Error codes for distinguishing failure modes
|
||||||
const ERROR_CODES = {
|
const ERROR_CODES = {
|
||||||
@@ -23,9 +22,6 @@ type UsageError = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fixed refresh interval (45 seconds)
|
|
||||||
const REFRESH_INTERVAL_SECONDS = 45;
|
|
||||||
|
|
||||||
// Helper to format reset time
|
// Helper to format reset time
|
||||||
function formatResetTime(unixTimestamp: number): string {
|
function formatResetTime(unixTimestamp: number): string {
|
||||||
const date = new Date(unixTimestamp * 1000);
|
const date = new Date(unixTimestamp * 1000);
|
||||||
@@ -63,95 +59,39 @@ function getWindowLabel(durationMins: number): { title: string; subtitle: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CodexUsagePopover() {
|
export function CodexUsagePopover() {
|
||||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<UsageError | null>(null);
|
|
||||||
|
|
||||||
// Check if Codex is authenticated
|
// Check if Codex is authenticated
|
||||||
const isCodexAuthenticated = codexAuthStatus?.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)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isStale = useMemo(() => {
|
const isStale = useMemo(() => {
|
||||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
|
||||||
}, [codexUsageLastUpdated]);
|
}, [dataUpdatedAt]);
|
||||||
|
|
||||||
const fetchUsage = useCallback(
|
// Convert query error to UsageError format for backward compatibility
|
||||||
async (isAutoRefresh = false) => {
|
const error = useMemo((): UsageError | null => {
|
||||||
if (!isAutoRefresh) setLoading(true);
|
if (!queryError) return null;
|
||||||
setError(null);
|
const message = queryError instanceof Error ? queryError.message : String(queryError);
|
||||||
try {
|
if (message.includes('not available') || message.includes('does not provide')) {
|
||||||
const api = getElectronAPI();
|
return { code: ERROR_CODES.NOT_AVAILABLE, message };
|
||||||
if (!api.codex) {
|
|
||||||
setError({
|
|
||||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
|
||||||
message: 'Codex API bridge not available',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const data = await api.codex.getUsage();
|
if (message.includes('bridge') || message.includes('API')) {
|
||||||
if ('error' in data) {
|
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
|
||||||
// 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;
|
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||||
}
|
}, [queryError]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [isStale, isCodexAuthenticated, fetchUsage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip if not authenticated
|
|
||||||
if (!isCodexAuthenticated) return;
|
|
||||||
|
|
||||||
// Initial fetch when opened
|
|
||||||
if (open) {
|
|
||||||
if (!codexUsage || 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, codexUsage, isStale, isCodexAuthenticated, fetchUsage]);
|
|
||||||
|
|
||||||
// Derived status color/icon helper
|
// Derived status color/icon helper
|
||||||
const getStatusInfo = (percentage: number) => {
|
const getStatusInfo = (percentage: number) => {
|
||||||
@@ -289,10 +229,10 @@ export function CodexUsagePopover() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('h-6 w-6', loading && 'opacity-80')}
|
className={cn('h-6 w-6', isFetching && 'opacity-80')}
|
||||||
onClick={() => !loading && fetchUsage(false)}
|
onClick={() => !isFetching && refetch()}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
|
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { useWorkspaceDirectories } from '@/hooks/queries';
|
||||||
|
|
||||||
interface WorkspaceDirectory {
|
interface WorkspaceDirectory {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,41 +23,15 @@ interface WorkspacePickerModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
|
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
// React Query hook - only fetch when modal is open
|
||||||
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
|
const { data: directories = [], isLoading, error, refetch } = useWorkspaceDirectories(open);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadDirectories = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = getHttpApiClient();
|
|
||||||
const result = await client.workspace.getDirectories();
|
|
||||||
|
|
||||||
if (result.success && result.directories) {
|
|
||||||
setDirectories(result.directories);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to load directories');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load directories');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load directories when modal opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
loadDirectories();
|
|
||||||
}
|
|
||||||
}, [open, loadDirectories]);
|
|
||||||
|
|
||||||
const handleSelect = (dir: WorkspaceDirectory) => {
|
const handleSelect = (dir: WorkspaceDirectory) => {
|
||||||
onSelect(dir.path, dir.name);
|
onSelect(dir.path, dir.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="bg-card border-border max-w-lg max-h-[80vh] flex flex-col">
|
<DialogContent className="bg-card border-border max-w-lg max-h-[80vh] flex flex-col">
|
||||||
@@ -80,19 +53,19 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !isLoading && (
|
{errorMessage && !isLoading && (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
||||||
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||||
<AlertCircle className="w-6 h-6 text-destructive" />
|
<AlertCircle className="w-6 h-6 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||||
<Button variant="secondary" size="sm" onClick={loadDirectories} className="mt-2">
|
<Button variant="secondary" size="sm" onClick={() => refetch()} className="mt-2">
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && directories.length === 0 && (
|
{!isLoading && !errorMessage && directories.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
||||||
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
|
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
|
||||||
<Folder className="w-6 h-6 text-muted-foreground" />
|
<Folder className="w-6 h-6 text-muted-foreground" />
|
||||||
@@ -103,7 +76,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && directories.length > 0 && (
|
{!isLoading && !errorMessage && directories.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{directories.map((dir) => (
|
{directories.map((dir) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
const logger = createLogger('SessionManager');
|
const logger = createLogger('SessionManager');
|
||||||
@@ -22,6 +23,8 @@ import { cn } from '@/lib/utils';
|
|||||||
import type { SessionListItem } from '@/types/electron';
|
import type { SessionListItem } from '@/types/electron';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { useSessions } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
|
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
|
||||||
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
|
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ export function SessionManager({
|
|||||||
onQuickCreateRef,
|
onQuickCreateRef,
|
||||||
}: SessionManagerProps) {
|
}: SessionManagerProps) {
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
|
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
|
||||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
@@ -113,8 +116,14 @@ export function SessionManager({
|
|||||||
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
||||||
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Use React Query for sessions list - always include archived, filter client-side
|
||||||
|
const { data: sessions = [], refetch: refetchSessions } = useSessions(true);
|
||||||
|
|
||||||
|
// Ref to track if we've done the initial running sessions check
|
||||||
|
const hasCheckedInitialRef = useRef(false);
|
||||||
|
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
const checkRunningSessions = useCallback(async (sessionList: SessionListItem[]) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.agent) return;
|
if (!api?.agent) return;
|
||||||
|
|
||||||
@@ -134,26 +143,26 @@ export function SessionManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRunningSessions(runningIds);
|
setRunningSessions(runningIds);
|
||||||
};
|
|
||||||
|
|
||||||
// Load sessions
|
|
||||||
const loadSessions = async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.sessions) return;
|
|
||||||
|
|
||||||
// Always load all sessions and filter client-side
|
|
||||||
const result = await api.sessions.list(true);
|
|
||||||
if (result.success && result.sessions) {
|
|
||||||
setSessions(result.sessions);
|
|
||||||
// Check running state for all sessions
|
|
||||||
await checkRunningSessions(result.sessions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSessions();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Helper to invalidate sessions cache and refetch
|
||||||
|
const invalidateSessions = useCallback(async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all(true) });
|
||||||
|
// Also check running state after invalidation
|
||||||
|
const result = await refetchSessions();
|
||||||
|
if (result.data) {
|
||||||
|
await checkRunningSessions(result.data);
|
||||||
|
}
|
||||||
|
}, [queryClient, refetchSessions, checkRunningSessions]);
|
||||||
|
|
||||||
|
// Check running state on initial load (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)
|
// Periodically check running state for sessions (useful for detecting when agents finish)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only poll if there are running sessions
|
// Only poll if there are running sessions
|
||||||
@@ -166,7 +175,7 @@ export function SessionManager({
|
|||||||
}, 3000); // Check every 3 seconds
|
}, 3000); // Check every 3 seconds
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
|
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
|
||||||
|
|
||||||
// Create new session with random name
|
// Create new session with random name
|
||||||
const handleCreateSession = async () => {
|
const handleCreateSession = async () => {
|
||||||
@@ -180,7 +189,7 @@ export function SessionManager({
|
|||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
setNewSessionName('');
|
setNewSessionName('');
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
onSelectSession(result.session.id);
|
onSelectSession(result.session.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -195,7 +204,7 @@ export function SessionManager({
|
|||||||
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
||||||
|
|
||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
onSelectSession(result.session.id);
|
onSelectSession(result.session.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -222,7 +231,7 @@ export function SessionManager({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
setEditingName('');
|
setEditingName('');
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,7 +250,7 @@ export function SessionManager({
|
|||||||
if (currentSessionId === sessionId) {
|
if (currentSessionId === sessionId) {
|
||||||
onSelectSession(null);
|
onSelectSession(null);
|
||||||
}
|
}
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
} else {
|
} else {
|
||||||
logger.error('[SessionManager] Archive failed:', result.error);
|
logger.error('[SessionManager] Archive failed:', result.error);
|
||||||
}
|
}
|
||||||
@@ -261,7 +270,7 @@ export function SessionManager({
|
|||||||
try {
|
try {
|
||||||
const result = await api.sessions.unarchive(sessionId);
|
const result = await api.sessions.unarchive(sessionId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
} else {
|
} else {
|
||||||
logger.error('[SessionManager] Unarchive failed:', result.error);
|
logger.error('[SessionManager] Unarchive failed:', result.error);
|
||||||
}
|
}
|
||||||
@@ -283,7 +292,7 @@ export function SessionManager({
|
|||||||
|
|
||||||
const result = await api.sessions.delete(sessionId);
|
const result = await api.sessions.delete(sessionId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
if (currentSessionId === sessionId) {
|
if (currentSessionId === sessionId) {
|
||||||
// Switch to another session or create a new one
|
// Switch to another session or create a new one
|
||||||
const activeSessionsList = sessions.filter((s) => !s.isArchived);
|
const activeSessionsList = sessions.filter((s) => !s.isArchived);
|
||||||
@@ -305,7 +314,7 @@ export function SessionManager({
|
|||||||
await api.sessions.delete(session.id);
|
await api.sessions.delete(session.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
setIsDeleteAllArchivedDialogOpen(false);
|
setIsDeleteAllArchivedDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
File,
|
File,
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
|
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus } from '@/types/electron';
|
||||||
|
|
||||||
interface GitDiffPanelProps {
|
interface GitDiffPanelProps {
|
||||||
@@ -350,56 +350,44 @@ export function GitDiffPanel({
|
|||||||
useWorktrees = false,
|
useWorktrees = false,
|
||||||
}: GitDiffPanelProps) {
|
}: GitDiffPanelProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(!compact);
|
const [isExpanded, setIsExpanded] = useState(!compact);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
|
||||||
const [diffContent, setDiffContent] = useState<string>('');
|
|
||||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const loadDiffs = useCallback(async () => {
|
// Use worktree diffs hook when worktrees are enabled and panel is expanded
|
||||||
setIsLoading(true);
|
// Pass undefined for featureId when not using worktrees to disable the query
|
||||||
setError(null);
|
const {
|
||||||
try {
|
data: worktreeDiffsData,
|
||||||
const api = getElectronAPI();
|
isLoading: isLoadingWorktree,
|
||||||
|
error: worktreeError,
|
||||||
|
refetch: refetchWorktree,
|
||||||
|
} = useWorktreeDiffs(
|
||||||
|
useWorktrees && isExpanded ? projectPath : undefined,
|
||||||
|
useWorktrees && isExpanded ? featureId : undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Use worktree API if worktrees are enabled, otherwise use git API for main project
|
// Use git diffs hook when worktrees are disabled and panel is expanded
|
||||||
if (useWorktrees) {
|
const {
|
||||||
if (!api?.worktree?.getDiffs) {
|
data: gitDiffsData,
|
||||||
throw new Error('Worktree API not available');
|
isLoading: isLoadingGit,
|
||||||
}
|
error: gitError,
|
||||||
const result = await api.worktree.getDiffs(projectPath, featureId);
|
refetch: refetchGit,
|
||||||
if (result.success) {
|
} = useGitDiffs(projectPath, !useWorktrees && isExpanded);
|
||||||
setFiles(result.files || []);
|
|
||||||
setDiffContent(result.diff || '');
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to load diffs');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use git API for main project diffs
|
|
||||||
if (!api?.git?.getDiffs) {
|
|
||||||
throw new Error('Git API not available');
|
|
||||||
}
|
|
||||||
const result = await api.git.getDiffs(projectPath);
|
|
||||||
if (result.success) {
|
|
||||||
setFiles(result.files || []);
|
|
||||||
setDiffContent(result.diff || '');
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to load diffs');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load diffs');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [projectPath, featureId, useWorktrees]);
|
|
||||||
|
|
||||||
// Load diffs when expanded
|
// Select the appropriate data based on useWorktrees prop
|
||||||
useEffect(() => {
|
const diffsData = useWorktrees ? worktreeDiffsData : gitDiffsData;
|
||||||
if (isExpanded) {
|
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
|
||||||
loadDiffs();
|
const queryError = useWorktrees ? worktreeError : gitError;
|
||||||
}
|
|
||||||
}, [isExpanded, loadDiffs]);
|
// Extract files and diff content from the data
|
||||||
|
const files: FileStatus[] = diffsData?.files ?? [];
|
||||||
|
const diffContent = diffsData?.diff ?? '';
|
||||||
|
const error = queryError
|
||||||
|
? queryError instanceof Error
|
||||||
|
? queryError.message
|
||||||
|
: 'Failed to load diffs'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Refetch function
|
||||||
|
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
|
||||||
|
|
||||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||||
|
|
||||||
|
|||||||
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
|
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
|
||||||
|
|
||||||
// Error codes for distinguishing failure modes
|
// Error codes for distinguishing failure modes
|
||||||
const ERROR_CODES = {
|
const ERROR_CODES = {
|
||||||
@@ -61,22 +60,63 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UsagePopover() {
|
export function UsagePopover() {
|
||||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
|
||||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude');
|
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
|
// Check authentication status
|
||||||
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||||
const isCodexAuthenticated = codexAuthStatus?.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
|
// Determine which tab to show by default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClaudeAuthenticated) {
|
if (isClaudeAuthenticated) {
|
||||||
@@ -95,137 +135,9 @@ export function UsagePopover() {
|
|||||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||||
}, [codexUsageLastUpdated]);
|
}, [codexUsageLastUpdated]);
|
||||||
|
|
||||||
const fetchClaudeUsage = useCallback(
|
// Refetch functions for manual refresh
|
||||||
async (isAutoRefresh = false) => {
|
const fetchClaudeUsage = () => refetchClaude();
|
||||||
if (!isAutoRefresh) setClaudeLoading(true);
|
const fetchCodexUsage = () => refetchCodex();
|
||||||
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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Derived status color/icon helper
|
// Derived status color/icon helper
|
||||||
const getStatusInfo = (percentage: number) => {
|
const getStatusInfo = (percentage: number) => {
|
||||||
@@ -417,7 +329,7 @@ export function UsagePopover() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
|
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
|
||||||
onClick={() => !claudeLoading && fetchClaudeUsage(false)}
|
onClick={() => !claudeLoading && fetchClaudeUsage()}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -524,7 +436,7 @@ export function UsagePopover() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
|
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
|
||||||
onClick={() => !codexLoading && fetchCodexUsage(false)}
|
onClick={() => !codexLoading && fetchCodexUsage()}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
|
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +74,7 @@ export function AnalysisView() {
|
|||||||
const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false);
|
const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false);
|
||||||
const [featureListGenerated, setFeatureListGenerated] = useState(false);
|
const [featureListGenerated, setFeatureListGenerated] = useState(false);
|
||||||
const [featureListError, setFeatureListError] = useState<string | null>(null);
|
const [featureListError, setFeatureListError] = useState<string | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Recursively scan directory
|
// Recursively scan directory
|
||||||
const scanDirectory = useCallback(
|
const scanDirectory = useCallback(
|
||||||
@@ -647,6 +650,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate React Query cache to sync UI
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
|
|
||||||
setFeatureListGenerated(true);
|
setFeatureListGenerated(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to generate feature list:', error);
|
logger.error('Failed to generate feature list:', error);
|
||||||
@@ -656,7 +664,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingFeatureList(false);
|
setIsGeneratingFeatureList(false);
|
||||||
}
|
}
|
||||||
}, [currentProject, projectAnalysis]);
|
}, [currentProject, projectAnalysis, queryClient]);
|
||||||
|
|
||||||
// Toggle folder expansion
|
// Toggle folder expansion
|
||||||
const toggleFolder = (path: string) => {
|
const toggleFolder = (path: string) => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { toast } from 'sonner';
|
|||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { useWindowState } from '@/hooks/use-window-state';
|
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 { MassEditDialog } from './board-view/dialogs';
|
||||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
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
|
// Stable empty array to avoid infinite loop in selector
|
||||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||||
@@ -108,9 +113,37 @@ export function BoardView() {
|
|||||||
isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
setPipelineConfig,
|
setPipelineConfig,
|
||||||
} = useAppStore();
|
} = useAppStore(
|
||||||
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
|
useShallow((state) => ({
|
||||||
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
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
|
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
||||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||||
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
|
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
|
||||||
@@ -953,9 +986,7 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build columnFeaturesMap for ListView
|
// Build columnFeaturesMap for ListView
|
||||||
const pipelineConfig = currentProject?.path
|
// pipelineConfig is now from usePipelineConfig React Query hook at the top
|
||||||
? pipelineConfigByProject[currentProject.path] || null
|
|
||||||
: null;
|
|
||||||
const columnFeaturesMap = useMemo(() => {
|
const columnFeaturesMap = useMemo(() => {
|
||||||
const columns = getColumnsWithPipeline(pipelineConfig);
|
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||||
const map: Record<string, typeof hookFeatures> = {};
|
const map: Record<string, typeof hookFeatures> = {};
|
||||||
@@ -1441,6 +1472,11 @@ export function BoardView() {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to save pipeline config');
|
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);
|
setPipelineConfig(currentProject.path, config);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// @ts-nocheck
|
import { memo, useEffect, useState, useMemo } from 'react';
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
|
||||||
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
||||||
import type { ReasoningEffort } from '@automaker/types';
|
import type { ReasoningEffort } from '@automaker/types';
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
@@ -16,6 +15,7 @@ import { Spinner } from '@/components/ui/spinner';
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { SummaryDialog } from './summary-dialog';
|
import { SummaryDialog } from './summary-dialog';
|
||||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||||
|
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats thinking level for compact display
|
* Formats thinking level for compact display
|
||||||
@@ -50,30 +50,62 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
|
|||||||
|
|
||||||
interface AgentInfoPanelProps {
|
interface AgentInfoPanelProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
|
projectPath: string;
|
||||||
contextContent?: string;
|
contextContent?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
isCurrentAutoTask?: boolean;
|
isCurrentAutoTask?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentInfoPanel({
|
export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||||
feature,
|
feature,
|
||||||
|
projectPath,
|
||||||
contextContent,
|
contextContent,
|
||||||
summary,
|
summary,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
}: AgentInfoPanelProps) {
|
}: AgentInfoPanelProps) {
|
||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||||
// Track real-time task status updates from WebSocket events
|
// Track real-time task status updates from WebSocket events
|
||||||
const [taskStatusMap, setTaskStatusMap] = useState<
|
const [taskStatusMap, setTaskStatusMap] = useState<
|
||||||
Map<string, 'pending' | 'in_progress' | 'completed'>
|
Map<string, 'pending' | 'in_progress' | 'completed'>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
// Fresh planSpec data fetched from API (store data is stale for task progress)
|
|
||||||
const [freshPlanSpec, setFreshPlanSpec] = useState<{
|
// Determine if we should poll for updates
|
||||||
tasks?: ParsedTask[];
|
const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
|
||||||
tasksCompleted?: number;
|
const shouldFetchData = feature.status !== 'backlog';
|
||||||
currentTaskId?: string;
|
|
||||||
} | null>(null);
|
// 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
|
// 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
|
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
||||||
@@ -125,73 +157,6 @@ export function AgentInfoPanel({
|
|||||||
taskStatusMap,
|
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
|
// Listen to WebSocket events for real-time task status updates
|
||||||
// This ensures the Kanban card shows the same progress as the Agent Output modal
|
// 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
|
// Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask
|
||||||
@@ -440,4 +405,4 @@ export function AgentInfoPanel({
|
|||||||
onOpenChange={setIsSummaryDialogOpen}
|
onOpenChange={setIsSummaryDialogOpen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
import { memo } from 'react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,7 @@ interface CardActionsProps {
|
|||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardActions({
|
export const CardActions = memo(function CardActions({
|
||||||
feature,
|
feature,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
hasContext,
|
hasContext,
|
||||||
@@ -344,4 +345,4 @@ export function CardActions({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
/** Uniform badge style for all card badges */
|
/** Uniform badge style for all card badges */
|
||||||
const uniformBadgeClass =
|
const uniformBadgeClass =
|
||||||
@@ -18,7 +19,7 @@ interface CardBadgesProps {
|
|||||||
* CardBadges - Shows error badges below the card header
|
* CardBadges - Shows error badges below the card header
|
||||||
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
|
* 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) {
|
if (!feature.error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -46,14 +47,19 @@ export function CardBadges({ feature }: CardBadgesProps) {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
interface PriorityBadgesProps {
|
interface PriorityBadgesProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||||
const { enableDependencyBlocking, features } = useAppStore();
|
const { enableDependencyBlocking, features } = useAppStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
|
features: state.features,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
|
|
||||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||||
@@ -223,4 +229,4 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
import { memo } from 'react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
@@ -7,7 +8,10 @@ interface CardContentSectionsProps {
|
|||||||
useWorktrees: boolean;
|
useWorktrees: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
|
export const CardContentSections = memo(function CardContentSections({
|
||||||
|
feature,
|
||||||
|
useWorktrees,
|
||||||
|
}: CardContentSectionsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Target Branch Display */}
|
{/* Target Branch Display */}
|
||||||
@@ -48,4 +52,4 @@ export function CardContentSections({ feature, useWorktrees }: CardContentSectio
|
|||||||
})()}
|
})()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -37,7 +37,7 @@ interface CardHeaderProps {
|
|||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeaderSection({
|
export const CardHeaderSection = memo(function CardHeaderSection({
|
||||||
feature,
|
feature,
|
||||||
isDraggable,
|
isDraggable,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
@@ -378,4 +378,4 @@ export function CardHeaderSection({
|
|||||||
/>
|
/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { CardBadges, PriorityBadges } from './card-badges';
|
import { CardBadges, PriorityBadges } from './card-badges';
|
||||||
import { CardHeaderSection } from './card-header';
|
import { CardHeaderSection } from './card-header';
|
||||||
import { CardContentSections } from './card-content-sections';
|
import { CardContentSections } from './card-content-sections';
|
||||||
@@ -61,6 +62,7 @@ interface KanbanCardProps {
|
|||||||
cardBorderEnabled?: boolean;
|
cardBorderEnabled?: boolean;
|
||||||
cardBorderOpacity?: number;
|
cardBorderOpacity?: number;
|
||||||
isOverlay?: boolean;
|
isOverlay?: boolean;
|
||||||
|
reduceEffects?: boolean;
|
||||||
// Selection mode props
|
// Selection mode props
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
@@ -94,12 +96,18 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
cardBorderEnabled = true,
|
cardBorderEnabled = true,
|
||||||
cardBorderOpacity = 100,
|
cardBorderOpacity = 100,
|
||||||
isOverlay,
|
isOverlay,
|
||||||
|
reduceEffects = false,
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
selectionTarget = null,
|
selectionTarget = null,
|
||||||
}: KanbanCardProps) {
|
}: KanbanCardProps) {
|
||||||
const { useWorktrees } = useAppStore();
|
const { useWorktrees, currentProject } = useAppStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
useWorktrees: state.useWorktrees,
|
||||||
|
currentProject: state.currentProject,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const [isLifted, setIsLifted] = useState(false);
|
const [isLifted, setIsLifted] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -140,9 +148,12 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
const hasError = feature.error && !isCurrentAutoTask;
|
const hasError = feature.error && !isCurrentAutoTask;
|
||||||
|
|
||||||
const innerCardClasses = cn(
|
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',
|
'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]!',
|
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||||
!isCurrentAutoTask &&
|
!isCurrentAutoTask &&
|
||||||
cardBorderEnabled &&
|
cardBorderEnabled &&
|
||||||
@@ -215,6 +226,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
{/* Agent Info Panel */}
|
{/* Agent Info Panel */}
|
||||||
<AgentInfoPanel
|
<AgentInfoPanel
|
||||||
feature={feature}
|
feature={feature}
|
||||||
|
projectPath={currentProject?.path ?? ''}
|
||||||
contextContent={contextContent}
|
contextContent={contextContent}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
isCurrentAutoTask={isCurrentAutoTask}
|
isCurrentAutoTask={isCurrentAutoTask}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ReactNode } from 'react';
|
import type { CSSProperties, ReactNode, Ref, UIEvent } from 'react';
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +17,11 @@ interface KanbanColumnProps {
|
|||||||
hideScrollbar?: boolean;
|
hideScrollbar?: boolean;
|
||||||
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
||||||
width?: number;
|
width?: number;
|
||||||
|
contentRef?: Ref<HTMLDivElement>;
|
||||||
|
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
|
||||||
|
contentClassName?: string;
|
||||||
|
contentStyle?: CSSProperties;
|
||||||
|
disableItemSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanColumn = memo(function KanbanColumn({
|
export const KanbanColumn = memo(function KanbanColumn({
|
||||||
@@ -31,6 +36,11 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
showBorder = true,
|
showBorder = true,
|
||||||
hideScrollbar = false,
|
hideScrollbar = false,
|
||||||
width,
|
width,
|
||||||
|
contentRef,
|
||||||
|
onScroll,
|
||||||
|
contentClassName,
|
||||||
|
contentStyle,
|
||||||
|
disableItemSpacing = false,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
|
||||||
@@ -78,14 +88,19 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
{/* Column Content */}
|
{/* Column Content */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 &&
|
hideScrollbar &&
|
||||||
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
||||||
// Smooth scrolling
|
// Smooth scrolling
|
||||||
'scroll-smooth',
|
'scroll-smooth',
|
||||||
// Add padding at bottom if there's a footer action
|
// Add padding at bottom if there's a footer action
|
||||||
footerAction && 'pb-14'
|
footerAction && 'pb-14',
|
||||||
|
contentClassName
|
||||||
)}
|
)}
|
||||||
|
ref={contentRef}
|
||||||
|
onScroll={onScroll}
|
||||||
|
style={contentStyle}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
|
|||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { extractSummary } from '@/lib/log-parser';
|
import { extractSummary } from '@/lib/log-parser';
|
||||||
|
import { useAgentOutput } from '@/hooks/queries';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
|
|
||||||
interface AgentOutputModalProps {
|
interface AgentOutputModalProps {
|
||||||
@@ -45,10 +46,30 @@ export function AgentOutputModal({
|
|||||||
branchName,
|
branchName,
|
||||||
}: AgentOutputModalProps) {
|
}: AgentOutputModalProps) {
|
||||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
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 [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
|
// Extract summary from output
|
||||||
const summary = useMemo(() => extractSummary(output), [output]);
|
const summary = useMemo(() => extractSummary(output), [output]);
|
||||||
@@ -57,7 +78,6 @@ export function AgentOutputModal({
|
|||||||
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
|
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const autoScrollRef = useRef(true);
|
const autoScrollRef = useRef(true);
|
||||||
const projectPathRef = useRef<string>('');
|
|
||||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||||
|
|
||||||
// Auto-scroll to bottom when output changes
|
// Auto-scroll to bottom when output changes
|
||||||
@@ -67,55 +87,6 @@ export function AgentOutputModal({
|
|||||||
}
|
}
|
||||||
}, [output]);
|
}, [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
|
// Listen to auto mode events and update output
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -274,8 +245,8 @@ export function AgentOutputModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newContent) {
|
if (newContent) {
|
||||||
// Only update local state - server is the single source of truth for file writes
|
// Append new content from WebSocket to streamed content
|
||||||
setOutput((prev) => prev + newContent);
|
setStreamedContent((prev) => prev + newContent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -426,16 +397,16 @@ export function AgentOutputModal({
|
|||||||
{!isBacklogPlan && (
|
{!isBacklogPlan && (
|
||||||
<TaskProgressPanel
|
<TaskProgressPanel
|
||||||
featureId={featureId}
|
featureId={featureId}
|
||||||
projectPath={projectPath}
|
projectPath={resolvedProjectPath}
|
||||||
className="shrink-0 mx-3 my-2"
|
className="shrink-0 mx-3 my-2"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{effectiveViewMode === 'changes' ? (
|
{effectiveViewMode === 'changes' ? (
|
||||||
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||||
{projectPath ? (
|
{resolvedProjectPath ? (
|
||||||
<GitDiffPanel
|
<GitDiffPanel
|
||||||
projectPath={projectPath}
|
projectPath={resolvedProjectPath}
|
||||||
featureId={branchName || featureId}
|
featureId={branchName || featureId}
|
||||||
compact={false}
|
compact={false}
|
||||||
useWorktrees={useWorktrees}
|
useWorktrees={useWorktrees}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,6 +17,7 @@ import { GitPullRequest, ExternalLink } from 'lucide-react';
|
|||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useWorktreeBranches } from '@/hooks/queries';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -54,12 +55,21 @@ export function CreatePRDialog({
|
|||||||
const [prUrl, setPrUrl] = useState<string | null>(null);
|
const [prUrl, setPrUrl] = useState<string | null>(null);
|
||||||
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
||||||
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
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
|
// Track whether an operation completed that warrants a refresh
|
||||||
const operationCompletedRef = useRef(false);
|
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
|
// Common state reset function to avoid duplication
|
||||||
const resetState = useCallback(() => {
|
const resetState = useCallback(() => {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
@@ -72,44 +82,13 @@ export function CreatePRDialog({
|
|||||||
setBrowserUrl(null);
|
setBrowserUrl(null);
|
||||||
setShowBrowserFallback(false);
|
setShowBrowserFallback(false);
|
||||||
operationCompletedRef.current = false;
|
operationCompletedRef.current = false;
|
||||||
setBranches([]);
|
|
||||||
}, [defaultBaseBranch]);
|
}, [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
|
// Reset state when dialog opens or worktree changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset all state on both open and close
|
// Reset all state on both open and close
|
||||||
resetState();
|
resetState();
|
||||||
if (open) {
|
}, [open, worktree?.path, resetState]);
|
||||||
// Fetch fresh branches when dialog opens
|
|
||||||
fetchBranches();
|
|
||||||
}
|
|
||||||
}, [open, worktree?.path, resetState, fetchBranches]);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
|
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
|
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
|
||||||
import { truncateDescription } from '@/lib/utils';
|
import { truncateDescription } from '@/lib/utils';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
@@ -94,6 +95,10 @@ export function useBoardActions({
|
|||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const autoMode = useAutoMode();
|
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
|
// Worktrees are created when adding/editing features with a branch name
|
||||||
// This ensures the worktree exists before the feature starts execution
|
// This ensures the worktree exists before the feature starts execution
|
||||||
|
|
||||||
@@ -553,28 +558,9 @@ export function useBoardActions({
|
|||||||
const handleVerifyFeature = useCallback(
|
const handleVerifyFeature = useCallback(
|
||||||
async (feature: Feature) => {
|
async (feature: Feature) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
verifyFeatureMutation.mutate(feature.id);
|
||||||
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();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[currentProject, loadFeatures]
|
[currentProject, verifyFeatureMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResumeFeature = useCallback(
|
const handleResumeFeature = useCallback(
|
||||||
@@ -584,40 +570,9 @@ export function useBoardActions({
|
|||||||
logger.error('No current project');
|
logger.error('No current project');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees });
|
||||||
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();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[currentProject, loadFeatures, useWorktrees]
|
[currentProject, resumeFeatureMutation, useWorktrees]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleManualVerify = useCallback(
|
const handleManualVerify = useCallback(
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
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'];
|
type ColumnId = Feature['status'];
|
||||||
|
|
||||||
@@ -32,6 +36,8 @@ export function useBoardColumnFeatures({
|
|||||||
verified: [],
|
verified: [],
|
||||||
completed: [], // Completed features are shown in the archive modal, not as a column
|
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)
|
// Filter features by search query (case-insensitive)
|
||||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
@@ -55,7 +61,7 @@ export function useBoardColumnFeatures({
|
|||||||
|
|
||||||
filteredFeatures.forEach((f) => {
|
filteredFeatures.forEach((f) => {
|
||||||
// If feature has a running agent, always show it in "in_progress"
|
// 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
|
// Check if feature matches the current worktree by branchName
|
||||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||||
@@ -168,7 +174,6 @@ export function useBoardColumnFeatures({
|
|||||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||||
|
|
||||||
// Get all features to check blocking dependencies against
|
// Get all features to check blocking dependencies against
|
||||||
const allFeatures = features;
|
|
||||||
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
||||||
|
|
||||||
// Sort blocked features to the end of the backlog
|
// Sort blocked features to the end of the backlog
|
||||||
@@ -178,7 +183,7 @@ export function useBoardColumnFeatures({
|
|||||||
const blocked: Feature[] = [];
|
const blocked: Feature[] = [];
|
||||||
|
|
||||||
for (const f of orderedFeatures) {
|
for (const f of orderedFeatures) {
|
||||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
if (getBlockingDependenciesFromMap(f, featureMap).length > 0) {
|
||||||
blocked.push(f);
|
blocked.push(f);
|
||||||
} else {
|
} else {
|
||||||
unblocked.push(f);
|
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 { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { useFeatures } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
const logger = createLogger('BoardFeatures');
|
const logger = createLogger('BoardFeatures');
|
||||||
|
|
||||||
@@ -11,105 +21,15 @@ interface UseBoardFeaturesProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||||
const { features, setFeatures } = useAppStore();
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||||
|
|
||||||
// Track previous project path to detect project switches
|
// Use React Query for features
|
||||||
const prevProjectPathRef = useRef<string | null>(null);
|
const {
|
||||||
const isInitialLoadRef = useRef(true);
|
data: features = [],
|
||||||
const isSwitchingProjectRef = useRef(false);
|
isLoading,
|
||||||
|
refetch: loadFeatures,
|
||||||
// Load features using features API
|
} = useFeatures(currentProject?.path);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Load persisted categories from file
|
// Load persisted categories from file
|
||||||
const loadCategories = useCallback(async () => {
|
const loadCategories = useCallback(async () => {
|
||||||
@@ -125,15 +45,12 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
setPersistedCategories(parsed);
|
setPersistedCategories(parsed);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// File doesn't exist, ensure categories are cleared
|
|
||||||
setPersistedCategories([]);
|
setPersistedCategories([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
logger.error('Failed to load categories:', error);
|
|
||||||
// If file doesn't exist, ensure categories are cleared
|
|
||||||
setPersistedCategories([]);
|
setPersistedCategories([]);
|
||||||
}
|
}
|
||||||
}, [currentProject]);
|
}, [currentProject, loadFeatures]);
|
||||||
|
|
||||||
// Save a new category to the persisted categories file
|
// Save a new category to the persisted categories file
|
||||||
const saveCategory = useCallback(
|
const saveCategory = useCallback(
|
||||||
@@ -142,22 +59,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
|
|
||||||
// Read existing categories
|
|
||||||
let categories: string[] = [...persistedCategories];
|
let categories: string[] = [...persistedCategories];
|
||||||
|
|
||||||
// Add new category if it doesn't exist
|
|
||||||
if (!categories.includes(category)) {
|
if (!categories.includes(category)) {
|
||||||
categories.push(category);
|
categories.push(category);
|
||||||
categories.sort(); // Keep sorted
|
categories.sort();
|
||||||
|
|
||||||
// Write back to file
|
|
||||||
await api.writeFile(
|
await api.writeFile(
|
||||||
`${currentProject.path}/.automaker/categories.json`,
|
`${currentProject.path}/.automaker/categories.json`,
|
||||||
JSON.stringify(categories, null, 2)
|
JSON.stringify(categories, null, 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update state
|
|
||||||
setPersistedCategories(categories);
|
setPersistedCategories(categories);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -167,29 +79,8 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
[currentProject, persistedCategories]
|
[currentProject, persistedCategories]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to spec regeneration complete events to refresh kanban board
|
// Subscribe to auto mode events for notifications (ding sound, toasts)
|
||||||
useEffect(() => {
|
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root
|
||||||
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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.autoMode || !currentProject) return;
|
if (!api?.autoMode || !currentProject) return;
|
||||||
@@ -229,28 +120,13 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
const audio = new Audio('/sounds/ding.mp3');
|
const audio = new Audio('/sounds/ding.mp3');
|
||||||
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
|
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') {
|
} else if (event.type === 'auto_mode_error') {
|
||||||
// Reload features when an error occurs (feature moved to waiting_approval)
|
// Remove from running tasks
|
||||||
logger.info('Feature error, reloading features...', event.error);
|
|
||||||
|
|
||||||
// Remove from running tasks so it moves to the correct column
|
|
||||||
if (event.featureId) {
|
if (event.featureId) {
|
||||||
removeRunningTask(eventProjectId, event.featureId);
|
removeRunningTask(eventProjectId, event.featureId);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFeatures();
|
// Show error toast
|
||||||
|
|
||||||
// Check for authentication errors and show a more helpful message
|
|
||||||
const isAuthError =
|
const isAuthError =
|
||||||
event.errorType === 'authentication' ||
|
event.errorType === 'authentication' ||
|
||||||
(event.error &&
|
(event.error &&
|
||||||
@@ -272,22 +148,46 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [loadFeatures, currentProject]);
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Check for interrupted features on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFeatures();
|
if (!currentProject) return;
|
||||||
}, [loadFeatures]);
|
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
loadCategories();
|
loadCategories();
|
||||||
}, [loadCategories]);
|
}, [loadCategories]);
|
||||||
|
|
||||||
|
// Clear categories when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
features,
|
features,
|
||||||
isLoading,
|
isLoading,
|
||||||
persistedCategories,
|
persistedCategories,
|
||||||
loadFeatures,
|
loadFeatures: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject?.path ?? ''),
|
||||||
|
});
|
||||||
|
},
|
||||||
loadCategories,
|
loadCategories,
|
||||||
saveCategory,
|
saveCategory,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
const logger = createLogger('BoardPersistence');
|
const logger = createLogger('BoardPersistence');
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ interface UseBoardPersistenceProps {
|
|||||||
|
|
||||||
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
|
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
|
||||||
const { updateFeature } = useAppStore();
|
const { updateFeature } = useAppStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Persist feature update to API (replaces saveFeatures)
|
// Persist feature update to API (replaces saveFeatures)
|
||||||
const persistFeatureUpdate = useCallback(
|
const persistFeatureUpdate = useCallback(
|
||||||
@@ -45,7 +48,21 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
feature: result.feature,
|
feature: result.feature,
|
||||||
});
|
});
|
||||||
if (result.success && 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) {
|
} else if (!result.success) {
|
||||||
logger.error('API features.update failed', result);
|
logger.error('API features.update failed', result);
|
||||||
}
|
}
|
||||||
@@ -53,7 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
logger.error('Failed to persist feature update:', error);
|
logger.error('Failed to persist feature update:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, updateFeature]
|
[currentProject, updateFeature, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Persist feature creation to API
|
// Persist feature creation to API
|
||||||
@@ -71,12 +88,16 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
const result = await api.features.create(currentProject.path, feature);
|
const result = await api.features.create(currentProject.path, feature);
|
||||||
if (result.success && result.feature) {
|
if (result.success && result.feature) {
|
||||||
updateFeature(result.feature.id, 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) {
|
} catch (error) {
|
||||||
logger.error('Failed to persist feature creation:', error);
|
logger.error('Failed to persist feature creation:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, updateFeature]
|
[currentProject, updateFeature, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Persist feature deletion to API
|
// Persist feature deletion to API
|
||||||
@@ -92,11 +113,15 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.features.delete(currentProject.path, featureId);
|
await api.features.delete(currentProject.path, featureId);
|
||||||
|
// Invalidate React Query cache to sync UI
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to persist feature deletion:', error);
|
logger.error('Failed to persist feature deletion:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject]
|
[currentProject, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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 { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -64,6 +65,199 @@ interface KanbanBoardProps {
|
|||||||
className?: string;
|
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({
|
export function KanbanBoard({
|
||||||
sensors,
|
sensors,
|
||||||
collisionDetectionStrategy,
|
collisionDetectionStrategy,
|
||||||
@@ -109,7 +303,7 @@ export function KanbanBoard({
|
|||||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||||
|
|
||||||
// Get the keyboard shortcut for adding features
|
// Get the keyboard shortcut for adding features
|
||||||
const { keyboardShortcuts } = useAppStore();
|
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||||
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
||||||
|
|
||||||
// Use responsive column widths based on window size
|
// Use responsive column widths based on window size
|
||||||
@@ -135,8 +329,27 @@ export function KanbanBoard({
|
|||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||||
return (
|
return (
|
||||||
<KanbanColumn
|
<VirtualizedList
|
||||||
key={column.id}
|
key={column.id}
|
||||||
|
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}
|
id={column.id}
|
||||||
title={column.title}
|
title={column.title}
|
||||||
colorClass={column.colorClass}
|
colorClass={column.colorClass}
|
||||||
@@ -145,6 +358,10 @@ export function KanbanBoard({
|
|||||||
opacity={backgroundSettings.columnOpacity}
|
opacity={backgroundSettings.columnOpacity}
|
||||||
showBorder={backgroundSettings.columnBorderEnabled}
|
showBorder={backgroundSettings.columnBorderEnabled}
|
||||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||||
|
contentRef={contentRef}
|
||||||
|
onScroll={shouldVirtualize ? onScroll : undefined}
|
||||||
|
disableItemSpacing={shouldVirtualize}
|
||||||
|
contentClassName="perf-contain"
|
||||||
headerAction={
|
headerAction={
|
||||||
column.id === 'verified' ? (
|
column.id === 'verified' ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -194,7 +411,9 @@ export function KanbanBoard({
|
|||||||
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
onClick={() => onToggleSelectionMode?.('backlog')}
|
onClick={() => onToggleSelectionMode?.('backlog')}
|
||||||
title={
|
title={
|
||||||
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
|
selectionTarget === 'backlog'
|
||||||
|
? 'Switch to Drag Mode'
|
||||||
|
: 'Select Multiple'
|
||||||
}
|
}
|
||||||
data-testid="selection-mode-button"
|
data-testid="selection-mode-button"
|
||||||
>
|
>
|
||||||
@@ -278,10 +497,16 @@ export function KanbanBoard({
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SortableContext
|
{(() => {
|
||||||
items={columnFeatures.map((f) => f.id)}
|
const reduceEffects = shouldVirtualize;
|
||||||
strategy={verticalListSortingStrategy}
|
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 */}
|
{/* Empty state card when column has no features */}
|
||||||
{columnFeatures.length === 0 && !isDragging && (
|
{columnFeatures.length === 0 && !isDragging && (
|
||||||
<EmptyStateCard
|
<EmptyStateCard
|
||||||
@@ -290,8 +515,8 @@ export function KanbanBoard({
|
|||||||
addFeatureShortcut={addFeatureShortcut}
|
addFeatureShortcut={addFeatureShortcut}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
||||||
opacity={backgroundSettings.cardOpacity}
|
opacity={effectiveCardOpacity}
|
||||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
glassmorphism={effectiveGlassmorphism}
|
||||||
customConfig={
|
customConfig={
|
||||||
column.isPipelineStep
|
column.isPipelineStep
|
||||||
? {
|
? {
|
||||||
@@ -302,8 +527,65 @@ export function KanbanBoard({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{columnFeatures.map((feature, index) => {
|
{shouldVirtualize ? (
|
||||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
<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;
|
let shortcutKey: string | undefined;
|
||||||
if (column.id === 'in_progress' && index < 10) {
|
if (column.id === 'in_progress' && index < 10) {
|
||||||
shortcutKey = index === 9 ? '0' : String(index + 1);
|
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||||
@@ -329,19 +611,25 @@ export function KanbanBoard({
|
|||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
opacity={backgroundSettings.cardOpacity}
|
opacity={effectiveCardOpacity}
|
||||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
glassmorphism={effectiveGlassmorphism}
|
||||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||||
|
reduceEffects={reduceEffects}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
selectionTarget={selectionTarget}
|
selectionTarget={selectionTarget}
|
||||||
isSelected={selectedFeatureIds.has(feature.id)}
|
isSelected={selectedFeatureIds.has(feature.id)}
|
||||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</KanbanColumn>
|
</KanbanColumn>
|
||||||
|
)}
|
||||||
|
</VirtualizedList>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,65 +1,46 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
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';
|
import type { EditorInfo } from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('AvailableEditors');
|
|
||||||
|
|
||||||
// Re-export EditorInfo for convenience
|
// Re-export EditorInfo for convenience
|
||||||
export type { EditorInfo };
|
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() {
|
export function useAvailableEditors() {
|
||||||
const [editors, setEditors] = useState<EditorInfo[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const { data: editors = [], isLoading } = useAvailableEditorsQuery();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* Use this when the user has installed/uninstalled editors
|
||||||
*/
|
*/
|
||||||
const refresh = useCallback(async () => {
|
const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
|
||||||
setIsRefreshing(true);
|
mutationFn: async () => {
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.worktree?.refreshEditors) {
|
|
||||||
// Fallback to regular fetch if refresh not available
|
|
||||||
await fetchAvailableEditors();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.refreshEditors();
|
const result = await api.worktree.refreshEditors();
|
||||||
if (result.success && result.result?.editors) {
|
if (!result.success) {
|
||||||
setEditors(result.result.editors);
|
throw new Error(result.error || 'Failed to refresh editors');
|
||||||
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return result.result?.editors ?? [];
|
||||||
logger.error('Failed to refresh editors:', error);
|
},
|
||||||
} finally {
|
onSuccess: (newEditors) => {
|
||||||
setIsRefreshing(false);
|
// Update the cache with new editors
|
||||||
}
|
queryClient.setQueryData(queryKeys.worktrees.editors(), newEditors);
|
||||||
}, [fetchAvailableEditors]);
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const refresh = useCallback(() => {
|
||||||
fetchAvailableEditors();
|
refreshMutate();
|
||||||
}, [fetchAvailableEditors]);
|
}, [refreshMutate]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
editors,
|
editors,
|
||||||
|
|||||||
@@ -1,66 +1,45 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useWorktreeBranches } from '@/hooks/queries';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import type { GitRepoStatus } from '../types';
|
||||||
import type { BranchInfo, GitRepoStatus } from '../types';
|
|
||||||
|
|
||||||
const logger = createLogger('Branches');
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export function useBranches() {
|
||||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
|
||||||
const [aheadCount, setAheadCount] = useState(0);
|
|
||||||
const [behindCount, setBehindCount] = useState(0);
|
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
|
||||||
const [branchFilter, setBranchFilter] = useState('');
|
const [branchFilter, setBranchFilter] = useState('');
|
||||||
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
|
|
||||||
isGitRepo: true,
|
|
||||||
hasCommits: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Helper to reset branch state to initial values */
|
const {
|
||||||
const resetBranchState = useCallback(() => {
|
data: branchData,
|
||||||
setBranches([]);
|
isLoading: isLoadingBranches,
|
||||||
setAheadCount(0);
|
refetch,
|
||||||
setBehindCount(0);
|
} = 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(
|
const fetchBranches = useCallback(
|
||||||
async (worktreePath: string) => {
|
(worktreePath: string) => {
|
||||||
setIsLoadingBranches(true);
|
if (worktreePath === currentWorktreePath) {
|
||||||
try {
|
// Same path - just refetch to get latest data
|
||||||
const api = getElectronAPI();
|
refetch();
|
||||||
if (!api?.worktree?.listBranches) {
|
} else {
|
||||||
logger.warn('List branches API not available');
|
// Different path - update the tracked path (triggers new query)
|
||||||
return;
|
setCurrentWorktreePath(worktreePath);
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resetBranchState]
|
[currentWorktreePath, refetch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetBranchFilter = useCallback(() => {
|
const resetBranchFilter = useCallback(() => {
|
||||||
|
|||||||
@@ -3,128 +3,53 @@ import { useNavigate } from '@tanstack/react-router';
|
|||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
useSwitchBranch,
|
||||||
|
usePullWorktree,
|
||||||
|
usePushWorktree,
|
||||||
|
useOpenInEditor,
|
||||||
|
} from '@/hooks/mutations';
|
||||||
import type { WorktreeInfo } from '../types';
|
import type { WorktreeInfo } from '../types';
|
||||||
|
|
||||||
const logger = createLogger('WorktreeActions');
|
const logger = createLogger('WorktreeActions');
|
||||||
|
|
||||||
// Error codes that need special user-friendly handling
|
export function useWorktreeActions() {
|
||||||
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) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isPulling, setIsPulling] = useState(false);
|
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
|
||||||
const [isActivating, setIsActivating] = 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(
|
const handleSwitchBranch = useCallback(
|
||||||
async (worktree: WorktreeInfo, branchName: string) => {
|
async (worktree: WorktreeInfo, branchName: string) => {
|
||||||
if (isSwitching || branchName === worktree.branch) return;
|
if (switchBranchMutation.isPending || branchName === worktree.branch) return;
|
||||||
setIsSwitching(true);
|
switchBranchMutation.mutate({
|
||||||
try {
|
worktreePath: worktree.path,
|
||||||
const api = getElectronAPI();
|
branchName,
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isSwitching, fetchWorktrees]
|
[switchBranchMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePull = useCallback(
|
const handlePull = useCallback(
|
||||||
async (worktree: WorktreeInfo) => {
|
async (worktree: WorktreeInfo) => {
|
||||||
if (isPulling) return;
|
if (pullMutation.isPending) return;
|
||||||
setIsPulling(true);
|
pullMutation.mutate(worktree.path);
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isPulling, fetchWorktrees]
|
[pullMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePush = useCallback(
|
const handlePush = useCallback(
|
||||||
async (worktree: WorktreeInfo) => {
|
async (worktree: WorktreeInfo) => {
|
||||||
if (isPushing) return;
|
if (pushMutation.isPending) return;
|
||||||
setIsPushing(true);
|
pushMutation.mutate({
|
||||||
try {
|
worktreePath: worktree.path,
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isPushing, fetchBranches, fetchWorktrees]
|
[pushMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenInIntegratedTerminal = useCallback(
|
const handleOpenInIntegratedTerminal = useCallback(
|
||||||
@@ -140,23 +65,15 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
const handleOpenInEditor = useCallback(
|
||||||
try {
|
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||||
const api = getElectronAPI();
|
openInEditorMutation.mutate({
|
||||||
if (!api?.worktree?.openInEditor) {
|
worktreePath: worktree.path,
|
||||||
logger.warn('Open in editor API not available');
|
editorCommand,
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
|
[openInEditorMutation]
|
||||||
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 handleOpenInExternalTerminal = useCallback(
|
const handleOpenInExternalTerminal = useCallback(
|
||||||
async (worktree: WorktreeInfo, terminalId?: string) => {
|
async (worktree: WorktreeInfo, terminalId?: string) => {
|
||||||
@@ -180,9 +97,9 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPulling,
|
isPulling: pullMutation.isPending,
|
||||||
isPushing,
|
isPushing: pushMutation.isPending,
|
||||||
isSwitching,
|
isSwitching: switchBranchMutation.isPending,
|
||||||
isActivating,
|
isActivating,
|
||||||
setIsActivating,
|
setIsActivating,
|
||||||
handleSwitchBranch,
|
handleSwitchBranch,
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAppStore } from '@/store/app-store';
|
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 { pathsEqual } from '@/lib/utils';
|
||||||
import type { WorktreeInfo } from '../types';
|
import type { WorktreeInfo } from '../types';
|
||||||
|
|
||||||
const logger = createLogger('Worktrees');
|
|
||||||
|
|
||||||
interface UseWorktreesOptions {
|
interface UseWorktreesOptions {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
@@ -18,62 +17,46 @@ export function useWorktrees({
|
|||||||
refreshTrigger = 0,
|
refreshTrigger = 0,
|
||||||
onRemovedWorktrees,
|
onRemovedWorktrees,
|
||||||
}: UseWorktreesOptions) {
|
}: UseWorktreesOptions) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const queryClient = useQueryClient();
|
||||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
|
||||||
|
|
||||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||||
|
|
||||||
const fetchWorktrees = useCallback(
|
// Use the React Query hook
|
||||||
async (options?: { silent?: boolean }) => {
|
const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
|
||||||
if (!projectPath) return;
|
const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Sync worktrees to Zustand store when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWorktrees();
|
if (worktrees.length > 0) {
|
||||||
}, [fetchWorktrees]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (refreshTrigger > 0) {
|
if (refreshTrigger > 0) {
|
||||||
fetchWorktrees().then((removedWorktrees) => {
|
// Invalidate and refetch to get fresh data including any removed worktrees
|
||||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
queryClient.invalidateQueries({
|
||||||
onRemovedWorktrees(removedWorktrees);
|
queryKey: queryKeys.worktrees.all(projectPath),
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
|
}, [refreshTrigger, projectPath, queryClient]);
|
||||||
|
|
||||||
// Use a ref to track the current worktree to avoid running validation
|
// 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)
|
// when selection changes (which could cause a race condition with stale worktrees list)
|
||||||
@@ -111,6 +94,14 @@ export function useWorktrees({
|
|||||||
[projectPath, setCurrentWorktree]
|
[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 currentWorktreePath = currentWorktree?.path ?? null;
|
||||||
const selectedWorktree = currentWorktreePath
|
const selectedWorktree = currentWorktreePath
|
||||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { pathsEqual } from '@/lib/utils';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
|
import { useWorktreeInitScript } from '@/hooks/queries';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||||
import {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
@@ -85,10 +86,7 @@ export function WorktreePanel({
|
|||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
} = useWorktreeActions({
|
} = useWorktreeActions();
|
||||||
fetchWorktrees,
|
|
||||||
fetchBranches,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { hasRunningFeatures } = useRunningFeatures({
|
const { hasRunningFeatures } = useRunningFeatures({
|
||||||
runningFeatureIds,
|
runningFeatureIds,
|
||||||
@@ -156,8 +154,9 @@ export function WorktreePanel({
|
|||||||
[currentProject, projectPath, isAutoModeRunningForWorktree]
|
[currentProject, projectPath, isAutoModeRunningForWorktree]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track whether init script exists for the project
|
// Check if init script exists for the project using React Query
|
||||||
const [hasInitScript, setHasInitScript] = useState(false);
|
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||||
|
const hasInitScript = initScriptData?.exists ?? false;
|
||||||
|
|
||||||
// View changes dialog state
|
// View changes dialog state
|
||||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||||
@@ -171,25 +170,6 @@ export function WorktreePanel({
|
|||||||
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
||||||
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
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();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Periodic interval check (5 seconds) to detect branch changes on disk
|
// 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 { useAppStore } from '@/store/app-store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +24,10 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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() {
|
export function ChatHistory() {
|
||||||
const {
|
const {
|
||||||
chatSessions,
|
chatSessions,
|
||||||
@@ -34,29 +40,117 @@ export function ChatHistory() {
|
|||||||
unarchiveChatSession,
|
unarchiveChatSession,
|
||||||
deleteChatSession,
|
deleteChatSession,
|
||||||
setChatHistoryOpen,
|
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 [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
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) {
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
return null;
|
const currentProjectId = currentProject?.id;
|
||||||
}
|
|
||||||
|
|
||||||
// Filter sessions for current project
|
// 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
|
// Filter by search query and archived status
|
||||||
const filteredSessions = projectSessions.filter((session) => {
|
const filteredSessions = useMemo(() => {
|
||||||
const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase());
|
return projectSessions.filter((session) => {
|
||||||
|
const matchesSearch = session.title.toLowerCase().includes(normalizedQuery);
|
||||||
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
|
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
|
||||||
return matchesSearch && matchesArchivedStatus;
|
return matchesSearch && matchesArchivedStatus;
|
||||||
});
|
});
|
||||||
|
}, [projectSessions, normalizedQuery, showArchived]);
|
||||||
|
|
||||||
// Sort by most recently updated
|
// Sort by most recently updated
|
||||||
const sortedSessions = filteredSessions.sort(
|
const sortedSessions = useMemo(() => {
|
||||||
|
return [...filteredSessions].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
(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 = () => {
|
const handleCreateNewChat = () => {
|
||||||
createChatSession();
|
createChatSession();
|
||||||
@@ -151,7 +245,11 @@ export function ChatHistory() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Sessions List */}
|
{/* 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 ? (
|
{sortedSessions.length === 0 ? (
|
||||||
<div className="p-4 text-center text-muted-foreground">
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
{searchQuery ? (
|
{searchQuery ? (
|
||||||
@@ -163,14 +261,26 @@ export function ChatHistory() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2">
|
<div
|
||||||
{sortedSessions.map((session) => (
|
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
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
|
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
|
||||||
currentChatSession?.id === session.id && 'bg-accent'
|
currentChatSession?.id === session.id && 'bg-accent'
|
||||||
)}
|
)}
|
||||||
|
style={{ height: CHAT_SESSION_ROW_HEIGHT_PX }}
|
||||||
onClick={() => handleSelectSession(session)}
|
onClick={() => handleSelectSession(session)}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -199,7 +309,9 @@ export function ChatHistory() {
|
|||||||
Unarchive
|
Unarchive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem onClick={(e) => handleArchiveSession(session.id, e)}>
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => handleArchiveSession(session.id, e)}
|
||||||
|
>
|
||||||
<Archive className="w-4 h-4 mr-2" />
|
<Archive className="w-4 h-4 mr-2" />
|
||||||
Archive
|
Archive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -218,6 +330,7 @@ export function ChatHistory() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
|
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { ErrorState } from '@/components/ui/error-state';
|
||||||
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
||||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||||
@@ -36,6 +38,7 @@ export function GitHubIssuesView() {
|
|||||||
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
||||||
|
|
||||||
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Model override for validation
|
// Model override for validation
|
||||||
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
||||||
@@ -153,6 +156,10 @@ export function GitHubIssuesView() {
|
|||||||
|
|
||||||
const result = await api.features.create(currentProject.path, feature);
|
const result = await api.features.create(currentProject.path, feature);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Invalidate React Query cache to sync UI
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
toast.success(`Created task: ${issue.title}`);
|
toast.success(`Created task: ${issue.title}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to create task');
|
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');
|
toast.error(err instanceof Error ? err.message : 'Failed to create task');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject?.path, currentBranch]
|
[currentProject?.path, currentBranch, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|||||||
@@ -1,79 +1,29 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
/**
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
* GitHub Issues Hook
|
||||||
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
|
*
|
||||||
|
* React Query-based hook for fetching GitHub issues.
|
||||||
|
*/
|
||||||
|
|
||||||
const logger = createLogger('GitHubIssues');
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useGitHubIssues as useGitHubIssuesQuery } from '@/hooks/queries';
|
||||||
|
|
||||||
export function useGithubIssues() {
|
export function useGithubIssues() {
|
||||||
const { currentProject } = useAppStore();
|
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 () => {
|
const {
|
||||||
if (!currentProject?.path) {
|
data,
|
||||||
if (isMountedRef.current) {
|
isLoading: loading,
|
||||||
setError('No project selected');
|
isFetching: refreshing,
|
||||||
setLoading(false);
|
error,
|
||||||
}
|
refetch: refresh,
|
||||||
return;
|
} = useGitHubIssuesQuery(currentProject?.path);
|
||||||
}
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openIssues,
|
openIssues: data?.openIssues ?? [],
|
||||||
closedIssues,
|
closedIssues: data?.closedIssues ?? [],
|
||||||
loading,
|
loading,
|
||||||
refreshing,
|
refreshing,
|
||||||
error,
|
error: error instanceof Error ? error.message : error ? String(error) : null,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import type { GitHubComment } from '@/lib/electron';
|
||||||
import { getElectronAPI, GitHubComment } from '@/lib/electron';
|
|
||||||
|
|
||||||
const logger = createLogger('IssueComments');
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useGitHubIssueComments } from '@/hooks/queries';
|
||||||
|
|
||||||
interface UseIssueCommentsResult {
|
interface UseIssueCommentsResult {
|
||||||
comments: GitHubComment[];
|
comments: GitHubComment[];
|
||||||
@@ -18,119 +16,36 @@ interface UseIssueCommentsResult {
|
|||||||
|
|
||||||
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
|
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
|
||||||
const { currentProject } = useAppStore();
|
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(
|
// Use React Query infinite query
|
||||||
async (cursor?: string) => {
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch, error } =
|
||||||
if (!currentProject?.path || !issueNumber) {
|
useGitHubIssueComments(currentProject?.path, issueNumber ?? undefined);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoadingMore = !!cursor;
|
// Flatten all pages into a single comments array
|
||||||
|
const comments = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.comments) ?? [];
|
||||||
|
}, [data?.pages]);
|
||||||
|
|
||||||
try {
|
// Get total count from the first page
|
||||||
if (isMountedRef.current) {
|
const totalCount = data?.pages[0]?.totalCount ?? 0;
|
||||||
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]);
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (hasNextPage && endCursor && !loadingMore) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchComments(endCursor);
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}, [hasNextPage, endCursor, loadingMore, fetchComments]);
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setComments([]);
|
refetch();
|
||||||
setEndCursor(undefined);
|
}, [refetch]);
|
||||||
fetchComments();
|
|
||||||
}, [fetchComments]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
comments,
|
comments,
|
||||||
totalCount,
|
totalCount,
|
||||||
loading,
|
loading: isLoading,
|
||||||
loadingMore,
|
loadingMore: isFetchingNextPage,
|
||||||
hasNextPage,
|
hasNextPage: hasNextPage ?? false,
|
||||||
error,
|
error: error instanceof Error ? error.message : null,
|
||||||
loadMore,
|
loadMore,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { isValidationStale } from '../utils';
|
import { isValidationStale } from '../utils';
|
||||||
|
import { useValidateIssue, useMarkValidationViewed } from '@/hooks/mutations';
|
||||||
|
|
||||||
const logger = createLogger('IssueValidation');
|
const logger = createLogger('IssueValidation');
|
||||||
|
|
||||||
@@ -46,6 +47,10 @@ export function useIssueValidation({
|
|||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
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)
|
// Refs for stable event handler (avoids re-subscribing on state changes)
|
||||||
const selectedIssueRef = useRef<GitHubIssue | null>(null);
|
const selectedIssueRef = useRef<GitHubIssue | null>(null);
|
||||||
const showValidationDialogRef = useRef(false);
|
const showValidationDialogRef = useRef(false);
|
||||||
@@ -240,7 +245,7 @@ export function useIssueValidation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if already validating this issue
|
// 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}`);
|
toast.info(`Validation already in progress for issue #${issue.number}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -254,11 +259,6 @@ export function useIssueValidation({
|
|||||||
return;
|
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
|
// 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)
|
// Extract model string and thinking level from PhaseModelEntry (handles both old string format and new object format)
|
||||||
const effectiveModelEntry = modelEntry
|
const effectiveModelEntry = modelEntry
|
||||||
@@ -276,40 +276,22 @@ export function useIssueValidation({
|
|||||||
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
|
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
|
||||||
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
|
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
|
||||||
|
|
||||||
try {
|
// Use mutation to trigger validation (toast is handled by mutation)
|
||||||
const api = getElectronAPI();
|
validateIssueMutation.mutate({
|
||||||
if (api.github?.validateIssue) {
|
issue,
|
||||||
const validationInput = {
|
model: modelToUse,
|
||||||
issueNumber: issue.number,
|
thinkingLevel: thinkingLevelToUse,
|
||||||
issueTitle: issue.title,
|
reasoningEffort: reasoningEffortToUse,
|
||||||
issueBody: issue.body || '',
|
comments,
|
||||||
issueLabels: issue.labels.map((l) => l.name),
|
linkedPRs,
|
||||||
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');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
currentProject?.path,
|
currentProject?.path,
|
||||||
validatingIssues,
|
validatingIssues,
|
||||||
cachedValidations,
|
cachedValidations,
|
||||||
phaseModels.validationModel,
|
phaseModels.validationModel,
|
||||||
|
validateIssueMutation,
|
||||||
onValidationResultChange,
|
onValidationResultChange,
|
||||||
onShowValidationDialogChange,
|
onShowValidationDialogChange,
|
||||||
]
|
]
|
||||||
@@ -325,10 +307,8 @@ export function useIssueValidation({
|
|||||||
|
|
||||||
// Mark as viewed if not already viewed
|
// Mark as viewed if not already viewed
|
||||||
if (!cached.viewedAt && currentProject?.path) {
|
if (!cached.viewedAt && currentProject?.path) {
|
||||||
try {
|
markViewedMutation.mutate(issue.number, {
|
||||||
const api = getElectronAPI();
|
onSuccess: () => {
|
||||||
if (api.github?.markValidationViewed) {
|
|
||||||
await api.github.markValidationViewed(currentProject.path, issue.number);
|
|
||||||
// Update local state
|
// Update local state
|
||||||
setCachedValidations((prev) => {
|
setCachedValidations((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -341,16 +321,15 @@ export function useIssueValidation({
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
} catch (err) {
|
});
|
||||||
logger.error('Failed to mark validation as viewed:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
cachedValidations,
|
cachedValidations,
|
||||||
currentProject?.path,
|
currentProject?.path,
|
||||||
|
markViewedMutation,
|
||||||
onValidationResultChange,
|
onValidationResultChange,
|
||||||
onShowValidationDialogChange,
|
onShowValidationDialogChange,
|
||||||
]
|
]
|
||||||
@@ -361,5 +340,6 @@ export function useIssueValidation({
|
|||||||
cachedValidations,
|
cachedValidations,
|
||||||
handleValidateIssue,
|
handleValidateIssue,
|
||||||
handleViewCachedValidation,
|
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 { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
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 { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useGitHubPRs } from '@/hooks/queries';
|
||||||
const logger = createLogger('GitHubPRsView');
|
|
||||||
|
|
||||||
export function GitHubPRsView() {
|
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 [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
const fetchPRs = useCallback(async () => {
|
const {
|
||||||
if (!currentProject?.path) {
|
data,
|
||||||
setError('No project selected');
|
isLoading: loading,
|
||||||
setLoading(false);
|
isFetching: refreshing,
|
||||||
return;
|
error,
|
||||||
}
|
refetch,
|
||||||
|
} = useGitHubPRs(currentProject?.path);
|
||||||
|
|
||||||
try {
|
const openPRs = data?.openPRs ?? [];
|
||||||
setError(null);
|
const mergedPRs = data?.mergedPRs ?? [];
|
||||||
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 handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
setRefreshing(true);
|
refetch();
|
||||||
fetchPRs();
|
}, [refetch]);
|
||||||
}, [fetchPRs]);
|
|
||||||
|
|
||||||
const handleOpenInGitHub = useCallback((url: string) => {
|
const handleOpenInGitHub = useCallback((url: string) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -99,7 +76,9 @@ export function GitHubPRsView() {
|
|||||||
<GitPullRequest className="h-12 w-12 text-destructive" />
|
<GitPullRequest className="h-12 w-12 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-medium mb-2">Failed to Load Pull Requests</h2>
|
<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}>
|
<Button variant="outline" onClick={handleRefresh}>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Try Again
|
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 justify-between p-3 border-b border-border bg-muted/30">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{selectedPR.state === 'MERGED' ? (
|
{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">
|
<span className="text-sm font-medium truncate">
|
||||||
#{selectedPR.number} {selectedPR.title}
|
#{selectedPR.number} {selectedPR.title}
|
||||||
@@ -210,7 +189,7 @@ export function GitHubPRsView() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -342,16 +321,16 @@ function PRRow({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{pr.state === 'MERGED' ? (
|
{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-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium truncate">{pr.title}</span>
|
<span className="text-sm font-medium truncate">{pr.title}</span>
|
||||||
{pr.isDraft && (
|
{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
|
Draft
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -402,7 +381,7 @@ function PRRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
|
className="shrink-0 opacity-0 group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenExternal();
|
onOpenExternal();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { useAppStore, Feature } from '@/store/app-store';
|
import { useAppStore, Feature } from '@/store/app-store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { GraphView } from './graph-view';
|
import { GraphView } from './graph-view';
|
||||||
import {
|
import {
|
||||||
EditFeatureDialog,
|
EditFeatureDialog,
|
||||||
@@ -40,7 +41,20 @@ export function GraphViewPage() {
|
|||||||
addFeatureUseSelectedWorktreeBranch,
|
addFeatureUseSelectedWorktreeBranch,
|
||||||
planUseSelectedWorktreeBranch,
|
planUseSelectedWorktreeBranch,
|
||||||
setPlanUseSelectedWorktreeBranch,
|
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
|
// Ensure worktrees are loaded when landing directly on graph view
|
||||||
useWorktrees({ projectPath: currentProject?.path ?? '' });
|
useWorktrees({ projectPath: currentProject?.path ?? '' });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { EdgeProps } from '@xyflow/react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { GRAPH_RENDER_MODE_COMPACT, type GraphRenderMode } from '../constants';
|
||||||
|
|
||||||
export interface DependencyEdgeData {
|
export interface DependencyEdgeData {
|
||||||
sourceStatus: Feature['status'];
|
sourceStatus: Feature['status'];
|
||||||
@@ -11,6 +12,7 @@ export interface DependencyEdgeData {
|
|||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
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 isHighlighted = edgeData?.isHighlighted ?? false;
|
||||||
const isDimmed = edgeData?.isDimmed ?? false;
|
const isDimmed = edgeData?.isDimmed ?? false;
|
||||||
|
const isCompact = edgeData?.renderMode === GRAPH_RENDER_MODE_COMPACT;
|
||||||
|
|
||||||
const edgeColor = isHighlighted
|
const edgeColor = isHighlighted
|
||||||
? 'var(--brand-500)'
|
? '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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Invisible wider path for hover detection */}
|
{/* Invisible wider path for hover detection */}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
||||||
|
import { GRAPH_RENDER_MODE_COMPACT } from '../constants';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -109,9 +110,11 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
|
|
||||||
// Background/theme settings with defaults
|
// Background/theme settings with defaults
|
||||||
const cardOpacity = data.cardOpacity ?? 100;
|
const cardOpacity = data.cardOpacity ?? 100;
|
||||||
const glassmorphism = data.cardGlassmorphism ?? true;
|
const shouldUseGlassmorphism = data.cardGlassmorphism ?? true;
|
||||||
const cardBorderEnabled = data.cardBorderEnabled ?? true;
|
const cardBorderEnabled = data.cardBorderEnabled ?? true;
|
||||||
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
|
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
|
// Get the border color based on status and error state
|
||||||
const borderColor = data.error
|
const borderColor = data.error
|
||||||
@@ -129,6 +132,99 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
// Get computed border style
|
// Get computed border style
|
||||||
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Target handle (left side - receives dependencies) */}
|
{/* 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 {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -39,6 +39,12 @@ import { useDebounceValue } from 'usehooks-ts';
|
|||||||
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
|
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
|
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
|
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -198,6 +204,17 @@ function GraphCanvasInner({
|
|||||||
// Calculate filter results
|
// Calculate filter results
|
||||||
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
|
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
|
// Transform features to nodes and edges with filter results
|
||||||
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
||||||
features,
|
features,
|
||||||
@@ -205,6 +222,8 @@ function GraphCanvasInner({
|
|||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks: nodeActionCallbacks,
|
actionCallbacks: nodeActionCallbacks,
|
||||||
backgroundSettings,
|
backgroundSettings,
|
||||||
|
renderMode,
|
||||||
|
enableEdgeAnimations: !isLargeGraph,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply layout
|
// Apply layout
|
||||||
@@ -457,6 +476,8 @@ function GraphCanvasInner({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const shouldRenderVisibleOnly = isLargeGraph;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
|
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -478,6 +499,7 @@ function GraphCanvasInner({
|
|||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
onlyRenderVisibleElements={shouldRenderVisibleOnly}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
className="graph-canvas"
|
className="graph-canvas"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function GraphView({
|
|||||||
planUseSelectedWorktreeBranch,
|
planUseSelectedWorktreeBranch,
|
||||||
onPlanUseSelectedWorktreeBranchChange,
|
onPlanUseSelectedWorktreeBranchChange,
|
||||||
}: GraphViewProps) {
|
}: GraphViewProps) {
|
||||||
const { currentProject } = useAppStore();
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
|
|
||||||
// Use the same background hook as the board view
|
// Use the same background hook as the board view
|
||||||
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
|
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
|
||||||
|
|||||||
@@ -54,18 +54,42 @@ function getAncestors(
|
|||||||
/**
|
/**
|
||||||
* Traverses down to find all descendants (features that depend on this one)
|
* 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;
|
if (visited.has(featureId)) return;
|
||||||
visited.add(featureId);
|
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) {
|
for (const feature of features) {
|
||||||
const deps = feature.dependencies as string[] | undefined;
|
const deps = feature.dependencies as string[] | undefined;
|
||||||
if (deps?.includes(featureId)) {
|
if (!deps || deps.length === 0) continue;
|
||||||
getDescendants(feature.id, features, visited);
|
|
||||||
|
for (const depId of deps) {
|
||||||
|
const existing = dependentsMap.get(depId);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(feature.id);
|
||||||
|
} else {
|
||||||
|
dependentsMap.set(depId, [feature.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return dependentsMap;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets all edges in the highlighted path
|
* Gets all edges in the highlighted path
|
||||||
*/
|
*/
|
||||||
@@ -91,9 +115,9 @@ function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[
|
|||||||
* Gets the effective status of a feature (accounting for running state)
|
* Gets the effective status of a feature (accounting for running state)
|
||||||
* Treats completed (archived) as verified
|
* 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') {
|
if (feature.status === 'in_progress') {
|
||||||
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
|
return runningTaskIds.has(feature.id) ? 'running' : 'paused';
|
||||||
}
|
}
|
||||||
// Treat completed (archived) as verified
|
// Treat completed (archived) as verified
|
||||||
if (feature.status === 'completed') {
|
if (feature.status === 'completed') {
|
||||||
@@ -119,6 +143,7 @@ export function useGraphFilter(
|
|||||||
).sort();
|
).sort();
|
||||||
|
|
||||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
|
const runningTaskIds = new Set(runningAutoTasks);
|
||||||
const hasSearchQuery = normalizedQuery.length > 0;
|
const hasSearchQuery = normalizedQuery.length > 0;
|
||||||
const hasCategoryFilter = selectedCategories.length > 0;
|
const hasCategoryFilter = selectedCategories.length > 0;
|
||||||
const hasStatusFilter = selectedStatuses.length > 0;
|
const hasStatusFilter = selectedStatuses.length > 0;
|
||||||
@@ -139,6 +164,7 @@ export function useGraphFilter(
|
|||||||
// Find directly matched nodes
|
// Find directly matched nodes
|
||||||
const matchedNodeIds = new Set<string>();
|
const matchedNodeIds = new Set<string>();
|
||||||
const featureMap = new Map(features.map((f) => [f.id, f]));
|
const featureMap = new Map(features.map((f) => [f.id, f]));
|
||||||
|
const dependentsMap = buildDependentsMap(features);
|
||||||
|
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
let matchesSearch = true;
|
let matchesSearch = true;
|
||||||
@@ -159,7 +185,7 @@ export function useGraphFilter(
|
|||||||
|
|
||||||
// Check status match
|
// Check status match
|
||||||
if (hasStatusFilter) {
|
if (hasStatusFilter) {
|
||||||
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
|
const effectiveStatus = getEffectiveStatus(feature, runningTaskIds);
|
||||||
matchesStatus = selectedStatuses.includes(effectiveStatus);
|
matchesStatus = selectedStatuses.includes(effectiveStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +216,7 @@ export function useGraphFilter(
|
|||||||
getAncestors(id, featureMap, highlightedNodeIds);
|
getAncestors(id, featureMap, highlightedNodeIds);
|
||||||
|
|
||||||
// Add all descendants (dependents)
|
// Add all descendants (dependents)
|
||||||
getDescendants(id, features, highlightedNodeIds);
|
getDescendants(id, dependentsMap, highlightedNodeIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get edges in the highlighted path
|
// Get edges in the highlighted path
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Node, Edge } from '@xyflow/react';
|
import { Node, Edge } from '@xyflow/react';
|
||||||
import { Feature } from '@/store/app-store';
|
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';
|
import { GraphFilterResult } from './use-graph-filter';
|
||||||
|
|
||||||
export interface TaskNodeData extends Feature {
|
export interface TaskNodeData extends Feature {
|
||||||
@@ -31,6 +32,7 @@ export interface TaskNodeData extends Feature {
|
|||||||
onResumeTask?: () => void;
|
onResumeTask?: () => void;
|
||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
onDeleteTask?: () => void;
|
onDeleteTask?: () => void;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskNode = Node<TaskNodeData, 'task'>;
|
export type TaskNode = Node<TaskNodeData, 'task'>;
|
||||||
@@ -40,6 +42,7 @@ export type DependencyEdge = Edge<{
|
|||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export interface NodeActionCallbacks {
|
export interface NodeActionCallbacks {
|
||||||
@@ -66,6 +69,8 @@ interface UseGraphNodesProps {
|
|||||||
filterResult?: GraphFilterResult;
|
filterResult?: GraphFilterResult;
|
||||||
actionCallbacks?: NodeActionCallbacks;
|
actionCallbacks?: NodeActionCallbacks;
|
||||||
backgroundSettings?: BackgroundSettings;
|
backgroundSettings?: BackgroundSettings;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
|
enableEdgeAnimations?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,14 +83,14 @@ export function useGraphNodes({
|
|||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks,
|
actionCallbacks,
|
||||||
backgroundSettings,
|
backgroundSettings,
|
||||||
|
renderMode = GRAPH_RENDER_MODE_FULL,
|
||||||
|
enableEdgeAnimations = true,
|
||||||
}: UseGraphNodesProps) {
|
}: UseGraphNodesProps) {
|
||||||
const { nodes, edges } = useMemo(() => {
|
const { nodes, edges } = useMemo(() => {
|
||||||
const nodeList: TaskNode[] = [];
|
const nodeList: TaskNode[] = [];
|
||||||
const edgeList: DependencyEdge[] = [];
|
const edgeList: DependencyEdge[] = [];
|
||||||
const featureMap = new Map<string, Feature>();
|
const featureMap = createFeatureMap(features);
|
||||||
|
const runningTaskIds = new Set(runningAutoTasks);
|
||||||
// Create feature map for quick lookups
|
|
||||||
features.forEach((f) => featureMap.set(f.id, f));
|
|
||||||
|
|
||||||
// Extract filter state
|
// Extract filter state
|
||||||
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
|
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
|
||||||
@@ -95,8 +100,8 @@ export function useGraphNodes({
|
|||||||
|
|
||||||
// Create nodes
|
// Create nodes
|
||||||
features.forEach((feature) => {
|
features.forEach((feature) => {
|
||||||
const isRunning = runningAutoTasks.includes(feature.id);
|
const isRunning = runningTaskIds.has(feature.id);
|
||||||
const blockingDeps = getBlockingDependencies(feature, features);
|
const blockingDeps = getBlockingDependenciesFromMap(feature, featureMap);
|
||||||
|
|
||||||
// Calculate filter highlight states
|
// Calculate filter highlight states
|
||||||
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
|
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
|
||||||
@@ -121,6 +126,7 @@ export function useGraphNodes({
|
|||||||
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
|
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
|
||||||
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
|
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
|
||||||
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
|
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
|
||||||
|
renderMode,
|
||||||
// Action callbacks (bound to this feature's ID)
|
// Action callbacks (bound to this feature's ID)
|
||||||
onViewLogs: actionCallbacks?.onViewLogs
|
onViewLogs: actionCallbacks?.onViewLogs
|
||||||
? () => actionCallbacks.onViewLogs!(feature.id)
|
? () => actionCallbacks.onViewLogs!(feature.id)
|
||||||
@@ -166,13 +172,14 @@ export function useGraphNodes({
|
|||||||
source: depId,
|
source: depId,
|
||||||
target: feature.id,
|
target: feature.id,
|
||||||
type: 'dependency',
|
type: 'dependency',
|
||||||
animated: isRunning || runningAutoTasks.includes(depId),
|
animated: enableEdgeAnimations && (isRunning || runningTaskIds.has(depId)),
|
||||||
data: {
|
data: {
|
||||||
sourceStatus: sourceFeature.status,
|
sourceStatus: sourceFeature.status,
|
||||||
targetStatus: feature.status,
|
targetStatus: feature.status,
|
||||||
isHighlighted: edgeIsHighlighted,
|
isHighlighted: edgeIsHighlighted,
|
||||||
isDimmed: edgeIsDimmed,
|
isDimmed: edgeIsDimmed,
|
||||||
onDeleteDependency: actionCallbacks?.onDeleteDependency,
|
onDeleteDependency: actionCallbacks?.onDeleteDependency,
|
||||||
|
renderMode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
edgeList.push(edge);
|
edgeList.push(edge);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||||
import { useIdeationStore } from '@/store/ideation-store';
|
import { useIdeationStore } from '@/store/ideation-store';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
|
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
|
||||||
@@ -28,6 +28,9 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
|
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
|
||||||
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
|
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// React Query mutation
|
||||||
|
const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
|
||||||
const {
|
const {
|
||||||
getPromptsByCategory,
|
getPromptsByCategory,
|
||||||
isLoading: isLoadingPrompts,
|
isLoading: isLoadingPrompts,
|
||||||
@@ -57,7 +60,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadingPromptId || generatingPromptIds.has(prompt.id)) return;
|
if (loadingPromptId || generateMutation.isPending || generatingPromptIds.has(prompt.id)) return;
|
||||||
|
|
||||||
setLoadingPromptId(prompt.id);
|
setLoadingPromptId(prompt.id);
|
||||||
|
|
||||||
@@ -69,17 +72,12 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
toast.info(`Generating ideas for "${prompt.title}"...`);
|
toast.info(`Generating ideas for "${prompt.title}"...`);
|
||||||
setMode('dashboard');
|
setMode('dashboard');
|
||||||
|
|
||||||
try {
|
generateMutation.mutate(
|
||||||
const api = getElectronAPI();
|
{ promptId: prompt.id, category },
|
||||||
const result = await api.ideation?.generateSuggestions(
|
{
|
||||||
currentProject.path,
|
onSuccess: (data) => {
|
||||||
prompt.id,
|
updateJobStatus(jobId, 'ready', data.suggestions);
|
||||||
category
|
toast.success(`Generated ${data.suggestions.length} ideas for "${prompt.title}"`, {
|
||||||
);
|
|
||||||
|
|
||||||
if (result?.success && result.suggestions) {
|
|
||||||
updateJobStatus(jobId, 'ready', result.suggestions);
|
|
||||||
toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, {
|
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
action: {
|
action: {
|
||||||
label: 'View Ideas',
|
label: 'View Ideas',
|
||||||
@@ -89,22 +87,16 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
updateJobStatus(
|
|
||||||
jobId,
|
|
||||||
'error',
|
|
||||||
undefined,
|
|
||||||
result?.error || 'Failed to generate suggestions'
|
|
||||||
);
|
|
||||||
toast.error(result?.error || 'Failed to generate suggestions');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate suggestions:', error);
|
|
||||||
updateJobStatus(jobId, 'error', undefined, (error as Error).message);
|
|
||||||
toast.error((error as Error).message);
|
|
||||||
} finally {
|
|
||||||
setLoadingPromptId(null);
|
setLoadingPromptId(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to generate suggestions:', error);
|
||||||
|
updateJobStatus(jobId, 'error', undefined, error.message);
|
||||||
|
toast.error(error.message);
|
||||||
|
setLoadingPromptId(null);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 { createLogger } from '@automaker/utils/logger';
|
||||||
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
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 { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
|
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
|
||||||
|
import { useRunningAgents } from '@/hooks/queries';
|
||||||
const logger = createLogger('RunningAgentsView');
|
import { useStopFeature } from '@/hooks/mutations';
|
||||||
|
|
||||||
export function RunningAgentsView() {
|
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 [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
|
||||||
const { setCurrentProject, projects } = useAppStore();
|
const { setCurrentProject, projects } = useAppStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const fetchRunningAgents = useCallback(async () => {
|
const logger = createLogger('RunningAgentsView');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Initial fetch
|
// Use React Query for running agents with auto-refresh
|
||||||
useEffect(() => {
|
const { data, isLoading, isFetching, refetch } = useRunningAgents();
|
||||||
fetchRunningAgents();
|
|
||||||
}, [fetchRunningAgents]);
|
|
||||||
|
|
||||||
// Auto-refresh every 2 seconds
|
const runningAgents = data?.agents ?? [];
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchRunningAgents();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
// Use mutation for stopping features
|
||||||
}, [fetchRunningAgents]);
|
const stopFeature = useStopFeature();
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
logger.debug('Manual refresh requested for running agents');
|
refetch();
|
||||||
setRefreshing(true);
|
}, [refetch]);
|
||||||
fetchRunningAgents();
|
|
||||||
}, [fetchRunningAgents]);
|
|
||||||
|
|
||||||
const handleStopAgent = useCallback(
|
const handleStopAgent = useCallback(
|
||||||
async (agent: RunningAgent) => {
|
async (agent: RunningAgent) => {
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
|
// Handle backlog plans separately - they use a different API
|
||||||
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
|
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
|
||||||
if (isBacklogPlan && api.backlogPlan) {
|
if (isBacklogPlan && api.backlogPlan) {
|
||||||
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
|
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
|
||||||
|
try {
|
||||||
await api.backlogPlan.stop();
|
await api.backlogPlan.stop();
|
||||||
fetchRunningAgents();
|
} catch (error) {
|
||||||
|
logger.error('Failed to stop backlog plan', { featureId: agent.featureId, error });
|
||||||
|
} finally {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (api.autoMode) {
|
// Use mutation for regular features
|
||||||
logger.debug('Stopping running agent', { featureId: agent.featureId });
|
stopFeature.mutate({ featureId: agent.featureId, projectPath: agent.projectPath });
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[fetchRunningAgents]
|
[stopFeature, refetch, logger]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNavigateToProject = useCallback(
|
const handleNavigateToProject = useCallback(
|
||||||
(agent: RunningAgent) => {
|
(agent: RunningAgent) => {
|
||||||
// Find the project by path
|
|
||||||
const project = projects.find((p) => p.path === agent.projectPath);
|
const project = projects.find((p) => p.path === agent.projectPath);
|
||||||
if (project) {
|
if (project) {
|
||||||
logger.debug('Navigating to running agent project', {
|
logger.debug('Navigating to running agent project', {
|
||||||
@@ -144,7 +87,7 @@ export function RunningAgentsView() {
|
|||||||
setSelectedAgent(agent);
|
setSelectedAgent(agent);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<Spinner size="xl" />
|
<Spinner size="xl" />
|
||||||
@@ -169,8 +112,8 @@ export function RunningAgentsView() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
|
||||||
{refreshing ? (
|
{isFetching ? (
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
@@ -258,7 +201,12 @@ export function RunningAgentsView() {
|
|||||||
>
|
>
|
||||||
View Project
|
View Project
|
||||||
</Button>
|
</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" />
|
<Square className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useClaudeUsage } from '@/hooks/queries';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { RefreshCw, AlertCircle } from 'lucide-react';
|
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
const ERROR_NO_API = 'Claude usage API not available';
|
|
||||||
const CLAUDE_USAGE_TITLE = 'Claude Usage';
|
const CLAUDE_USAGE_TITLE = 'Claude Usage';
|
||||||
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
|
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
|
||||||
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
|
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 =
|
const CLAUDE_NO_USAGE_MESSAGE =
|
||||||
'Usage limits are not available yet. Try refreshing if this persists.';
|
'Usage limits are not available yet. Try refreshing if this persists.';
|
||||||
const UPDATED_LABEL = 'Updated';
|
const UPDATED_LABEL = 'Updated';
|
||||||
const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
|
|
||||||
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
|
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
|
||||||
const WARNING_THRESHOLD = 75;
|
const WARNING_THRESHOLD = 75;
|
||||||
const CAUTION_THRESHOLD = 50;
|
const CAUTION_THRESHOLD = 50;
|
||||||
const MAX_PERCENTAGE = 100;
|
const MAX_PERCENTAGE = 100;
|
||||||
const REFRESH_INTERVAL_MS = 60_000;
|
|
||||||
const STALE_THRESHOLD_MS = 2 * 60_000;
|
|
||||||
// Using purple/indigo for Claude branding
|
// Using purple/indigo for Claude branding
|
||||||
const USAGE_COLOR_CRITICAL = 'bg-red-500';
|
const USAGE_COLOR_CRITICAL = 'bg-red-500';
|
||||||
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
||||||
@@ -81,77 +76,31 @@ function UsageCard({
|
|||||||
|
|
||||||
export function ClaudeUsageSection() {
|
export function ClaudeUsageSection() {
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
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;
|
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
|
// If we have usage data, we can show it even if auth status is unsure
|
||||||
const hasUsage = !!claudeUsage;
|
const hasUsage = !!claudeUsage;
|
||||||
|
|
||||||
const lastUpdatedLabel = claudeUsageLastUpdated
|
const lastUpdatedLabel = useMemo(() => {
|
||||||
? new Date(claudeUsageLastUpdated).toLocaleString()
|
return dataUpdatedAt ? new Date(dataUpdatedAt).toLocaleString() : null;
|
||||||
: null;
|
}, [dataUpdatedAt]);
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
|
||||||
|
|
||||||
const showAuthWarning =
|
const showAuthWarning =
|
||||||
(!canFetchUsage && !hasUsage && !isLoading) ||
|
(!canFetchUsage && !hasUsage && !isLoading) ||
|
||||||
(error && error.includes('Authentication required'));
|
(errorMessage && errorMessage.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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -173,13 +122,13 @@ export function ClaudeUsageSection() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={fetchUsage}
|
onClick={() => refetch()}
|
||||||
disabled={isLoading}
|
disabled={isFetching}
|
||||||
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
||||||
data-testid="refresh-claude-usage"
|
data-testid="refresh-claude-usage"
|
||||||
title={CLAUDE_REFRESH_LABEL}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
|
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
|
||||||
@@ -195,10 +144,10 @@ export function ClaudeUsageSection() {
|
|||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -220,7 +169,7 @@ export function ClaudeUsageSection() {
|
|||||||
</div>
|
</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">
|
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||||
{CLAUDE_NO_USAGE_MESSAGE}
|
{CLAUDE_NO_USAGE_MESSAGE}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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() {
|
function ClaudeCliStatusSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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() {
|
function CodexCliStatusSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -20,10 +21,6 @@ interface CursorCliStatusProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonPulse({ className }: { className?: string }) {
|
|
||||||
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CursorCliStatusSkeleton() {
|
export function CursorCliStatusSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -75,10 +76,6 @@ interface OpencodeCliStatusProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonPulse({ className }: { className?: string }) {
|
|
||||||
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpencodeCliStatusSkeleton() {
|
export function OpencodeCliStatusSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { RefreshCw, AlertCircle } from 'lucide-react';
|
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import {
|
import {
|
||||||
formatCodexPlanType,
|
formatCodexPlanType,
|
||||||
formatCodexResetTime,
|
formatCodexResetTime,
|
||||||
getCodexWindowLabel,
|
getCodexWindowLabel,
|
||||||
} from '@/lib/codex-usage-format';
|
} from '@/lib/codex-usage-format';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
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_TITLE = 'Codex Usage';
|
||||||
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
|
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
|
||||||
const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.';
|
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 =
|
const CODEX_NO_USAGE_MESSAGE =
|
||||||
'Usage limits are not available yet. Try refreshing if this persists.';
|
'Usage limits are not available yet. Try refreshing if this persists.';
|
||||||
const UPDATED_LABEL = 'Updated';
|
const UPDATED_LABEL = 'Updated';
|
||||||
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
|
|
||||||
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
|
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
|
||||||
const PLAN_LABEL = 'Plan';
|
const PLAN_LABEL = 'Plan';
|
||||||
const WARNING_THRESHOLD = 75;
|
const WARNING_THRESHOLD = 75;
|
||||||
const CAUTION_THRESHOLD = 50;
|
const CAUTION_THRESHOLD = 50;
|
||||||
const MAX_PERCENTAGE = 100;
|
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_CRITICAL = 'bg-red-500';
|
||||||
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
||||||
const USAGE_COLOR_OK = 'bg-emerald-500';
|
const USAGE_COLOR_OK = 'bg-emerald-500';
|
||||||
@@ -40,11 +34,12 @@ const isRateLimitWindow = (
|
|||||||
|
|
||||||
export function CodexUsageSection() {
|
export function CodexUsageSection() {
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
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;
|
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 rateLimits = codexUsage?.rateLimits ?? null;
|
||||||
const primary = rateLimits?.primary ?? null;
|
const primary = rateLimits?.primary ?? null;
|
||||||
const secondary = rateLimits?.secondary ?? null;
|
const secondary = rateLimits?.secondary ?? null;
|
||||||
@@ -55,46 +50,7 @@ export function CodexUsageSection() {
|
|||||||
? new Date(codexUsage.lastUpdated).toLocaleString()
|
? new Date(codexUsage.lastUpdated).toLocaleString()
|
||||||
: null;
|
: null;
|
||||||
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
|
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
|
||||||
const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS;
|
const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
|
||||||
|
|
||||||
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 getUsageColor = (percentage: number) => {
|
const getUsageColor = (percentage: number) => {
|
||||||
if (percentage >= WARNING_THRESHOLD) {
|
if (percentage >= WARNING_THRESHOLD) {
|
||||||
@@ -163,13 +119,13 @@ export function CodexUsageSection() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={fetchUsage}
|
onClick={() => refetch()}
|
||||||
disabled={isLoading}
|
disabled={isFetching}
|
||||||
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
||||||
data-testid="refresh-codex-usage"
|
data-testid="refresh-codex-usage"
|
||||||
title={CODEX_REFRESH_LABEL}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
|
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
|
||||||
@@ -183,10 +139,10 @@ export function CodexUsageSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{errorMessage && (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasMetrics && (
|
{hasMetrics && (
|
||||||
@@ -211,7 +167,7 @@ export function CodexUsageSection() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||||
{CODEX_NO_USAGE_MESSAGE}
|
{CODEX_NO_USAGE_MESSAGE}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,103 +1,52 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useCursorPermissionsQuery, type CursorPermissionsData } from '@/hooks/queries';
|
||||||
import { toast } from 'sonner';
|
import { useApplyCursorProfile, useCopyCursorConfig } from '@/hooks/mutations';
|
||||||
|
|
||||||
const logger = createLogger('CursorPermissions');
|
// Re-export for backward compatibility
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
export type PermissionsData = CursorPermissionsData;
|
||||||
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[] };
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing Cursor CLI permissions
|
* Custom hook for managing Cursor CLI permissions
|
||||||
* Handles loading permissions data, applying profiles, and copying configs
|
* Handles loading permissions data, applying profiles, and copying configs
|
||||||
*/
|
*/
|
||||||
export function useCursorPermissions(projectPath?: string) {
|
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);
|
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||||
|
|
||||||
// Load permissions data
|
// React Query hooks
|
||||||
const loadPermissions = useCallback(async () => {
|
const permissionsQuery = useCursorPermissionsQuery(projectPath);
|
||||||
setIsLoadingPermissions(true);
|
const applyProfileMutation = useApplyCursorProfile(projectPath);
|
||||||
try {
|
const copyConfigMutation = useCopyCursorConfig();
|
||||||
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]);
|
|
||||||
|
|
||||||
// Apply a permission profile
|
// Apply a permission profile
|
||||||
const applyProfile = useCallback(
|
const applyProfile = useCallback(
|
||||||
async (profileId: 'strict' | 'development', scope: 'global' | 'project') => {
|
(profileId: 'strict' | 'development', scope: 'global' | 'project') => {
|
||||||
setIsSavingPermissions(true);
|
applyProfileMutation.mutate({ profileId, scope });
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[projectPath, loadPermissions]
|
[applyProfileMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Copy example config to clipboard
|
// Copy example config to clipboard
|
||||||
const copyConfig = useCallback(async (profileId: 'strict' | 'development') => {
|
const copyConfig = useCallback(
|
||||||
try {
|
(profileId: 'strict' | 'development') => {
|
||||||
const api = getHttpApiClient();
|
copyConfigMutation.mutate(profileId, {
|
||||||
const result = await api.setup.getCursorExampleConfig(profileId);
|
onSuccess: () => {
|
||||||
|
|
||||||
if (result.success && result.config) {
|
|
||||||
await navigator.clipboard.writeText(result.config);
|
|
||||||
setCopiedConfig(true);
|
setCopiedConfig(true);
|
||||||
toast.success('Config copied to clipboard');
|
|
||||||
setTimeout(() => setCopiedConfig(false), 2000);
|
setTimeout(() => setCopiedConfig(false), 2000);
|
||||||
}
|
},
|
||||||
} catch (error) {
|
});
|
||||||
toast.error('Failed to copy config');
|
},
|
||||||
}
|
[copyConfigMutation]
|
||||||
}, []);
|
);
|
||||||
|
|
||||||
|
// Load permissions (refetch)
|
||||||
|
const loadPermissions = useCallback(() => {
|
||||||
|
permissionsQuery.refetch();
|
||||||
|
}, [permissionsQuery]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
permissions,
|
permissions: permissionsQuery.data ?? null,
|
||||||
isLoadingPermissions,
|
isLoadingPermissions: permissionsQuery.isLoading,
|
||||||
isSavingPermissions,
|
isSavingPermissions: applyProfileMutation.isPending,
|
||||||
copiedConfig,
|
copiedConfig,
|
||||||
loadPermissions,
|
loadPermissions,
|
||||||
applyProfile,
|
applyProfile,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useEffect, useMemo, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useCursorCliStatus } from '@/hooks/queries';
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
const logger = createLogger('CursorStatus');
|
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
|
||||||
export interface CursorStatus {
|
export interface CursorStatus {
|
||||||
@@ -15,52 +11,42 @@ export interface CursorStatus {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing Cursor CLI status
|
* 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() {
|
export function useCursorStatus() {
|
||||||
const { setCursorCliStatus } = useSetupStore();
|
const { setCursorCliStatus } = useSetupStore();
|
||||||
|
const { data: result, isLoading, refetch } = useCursorCliStatus();
|
||||||
|
|
||||||
const [status, setStatus] = useState<CursorStatus | null>(null);
|
// Transform the API result into the local CursorStatus shape
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const status = useMemo((): CursorStatus | null => {
|
||||||
|
if (!result) return null;
|
||||||
const loadData = useCallback(async () => {
|
return {
|
||||||
setIsLoading(true);
|
installed: result.installed ?? false,
|
||||||
try {
|
version: result.version ?? undefined,
|
||||||
const api = getHttpApiClient();
|
authenticated: result.auth?.authenticated ?? false,
|
||||||
const statusResult = await api.setup.getCursorStatus();
|
method: result.auth?.method,
|
||||||
|
|
||||||
if (statusResult.success) {
|
|
||||||
const newStatus = {
|
|
||||||
installed: statusResult.installed ?? false,
|
|
||||||
version: statusResult.version ?? undefined,
|
|
||||||
authenticated: statusResult.auth?.authenticated ?? false,
|
|
||||||
method: statusResult.auth?.method,
|
|
||||||
};
|
};
|
||||||
setStatus(newStatus);
|
}, [result]);
|
||||||
|
|
||||||
// Also update the global setup store so other components can access the status
|
// Keep the global setup store in sync with query data
|
||||||
|
useEffect(() => {
|
||||||
|
if (status) {
|
||||||
setCursorCliStatus({
|
setCursorCliStatus({
|
||||||
installed: newStatus.installed,
|
installed: status.installed,
|
||||||
version: newStatus.version,
|
version: status.version,
|
||||||
auth: newStatus.authenticated
|
auth: status.authenticated
|
||||||
? {
|
? {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
method: newStatus.method || 'unknown',
|
method: status.method || 'unknown',
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}, [status, setCursorCliStatus]);
|
||||||
logger.error('Failed to load Cursor settings:', error);
|
|
||||||
toast.error('Failed to load Cursor settings');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [setCursorCliStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = useCallback(() => {
|
||||||
loadData();
|
refetch();
|
||||||
}, [loadData]);
|
}, [refetch]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -5,59 +5,53 @@
|
|||||||
* configuring which sources to load Skills from (user/project).
|
* configuring which sources to load Skills from (user/project).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useUpdateGlobalSettings } from '@/hooks/mutations';
|
||||||
|
|
||||||
export function useSkillsSettings() {
|
export function useSkillsSettings() {
|
||||||
const enabled = useAppStore((state) => state.enableSkills);
|
const enabled = useAppStore((state) => state.enableSkills);
|
||||||
const sources = useAppStore((state) => state.skillsSources);
|
const sources = useAppStore((state) => state.skillsSources);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const updateEnabled = async (newEnabled: boolean) => {
|
// React Query mutation (disable default toast)
|
||||||
setIsLoading(true);
|
const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
const updateEnabled = useCallback(
|
||||||
if (!api.settings) {
|
(newEnabled: boolean) => {
|
||||||
throw new Error('Settings API not available');
|
updateSettingsMutation.mutate(
|
||||||
}
|
{ enableSkills: newEnabled },
|
||||||
await api.settings.updateGlobal({ enableSkills: newEnabled });
|
{
|
||||||
// Update local store after successful server update
|
onSuccess: () => {
|
||||||
useAppStore.setState({ enableSkills: newEnabled });
|
useAppStore.setState({ enableSkills: newEnabled });
|
||||||
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
|
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
|
||||||
} catch (error) {
|
},
|
||||||
toast.error('Failed to update skills settings');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[updateSettingsMutation]
|
||||||
|
);
|
||||||
|
|
||||||
const updateSources = async (newSources: Array<'user' | 'project'>) => {
|
const updateSources = useCallback(
|
||||||
setIsLoading(true);
|
(newSources: Array<'user' | 'project'>) => {
|
||||||
try {
|
updateSettingsMutation.mutate(
|
||||||
const api = getElectronAPI();
|
{ skillsSources: newSources },
|
||||||
if (!api.settings) {
|
{
|
||||||
throw new Error('Settings API not available');
|
onSuccess: () => {
|
||||||
}
|
|
||||||
await api.settings.updateGlobal({ skillsSources: newSources });
|
|
||||||
// Update local store after successful server update
|
|
||||||
useAppStore.setState({ skillsSources: newSources });
|
useAppStore.setState({ skillsSources: newSources });
|
||||||
toast.success('Skills sources updated');
|
toast.success('Skills sources updated');
|
||||||
} catch (error) {
|
},
|
||||||
toast.error('Failed to update skills sources');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[updateSettingsMutation]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
sources,
|
sources,
|
||||||
updateEnabled,
|
updateEnabled,
|
||||||
updateSources,
|
updateSources,
|
||||||
isLoading,
|
isLoading: updateSettingsMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,59 +5,53 @@
|
|||||||
* configuring which sources to load Subagents from (user/project).
|
* configuring which sources to load Subagents from (user/project).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useUpdateGlobalSettings } from '@/hooks/mutations';
|
||||||
|
|
||||||
export function useSubagentsSettings() {
|
export function useSubagentsSettings() {
|
||||||
const enabled = useAppStore((state) => state.enableSubagents);
|
const enabled = useAppStore((state) => state.enableSubagents);
|
||||||
const sources = useAppStore((state) => state.subagentsSources);
|
const sources = useAppStore((state) => state.subagentsSources);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const updateEnabled = async (newEnabled: boolean) => {
|
// React Query mutation (disable default toast)
|
||||||
setIsLoading(true);
|
const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
const updateEnabled = useCallback(
|
||||||
if (!api.settings) {
|
(newEnabled: boolean) => {
|
||||||
throw new Error('Settings API not available');
|
updateSettingsMutation.mutate(
|
||||||
}
|
{ enableSubagents: newEnabled },
|
||||||
await api.settings.updateGlobal({ enableSubagents: newEnabled });
|
{
|
||||||
// Update local store after successful server update
|
onSuccess: () => {
|
||||||
useAppStore.setState({ enableSubagents: newEnabled });
|
useAppStore.setState({ enableSubagents: newEnabled });
|
||||||
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
|
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
|
||||||
} catch (error) {
|
},
|
||||||
toast.error('Failed to update subagents settings');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[updateSettingsMutation]
|
||||||
|
);
|
||||||
|
|
||||||
const updateSources = async (newSources: Array<'user' | 'project'>) => {
|
const updateSources = useCallback(
|
||||||
setIsLoading(true);
|
(newSources: Array<'user' | 'project'>) => {
|
||||||
try {
|
updateSettingsMutation.mutate(
|
||||||
const api = getElectronAPI();
|
{ subagentsSources: newSources },
|
||||||
if (!api.settings) {
|
{
|
||||||
throw new Error('Settings API not available');
|
onSuccess: () => {
|
||||||
}
|
|
||||||
await api.settings.updateGlobal({ subagentsSources: newSources });
|
|
||||||
// Update local store after successful server update
|
|
||||||
useAppStore.setState({ subagentsSources: newSources });
|
useAppStore.setState({ subagentsSources: newSources });
|
||||||
toast.success('Subagents sources updated');
|
toast.success('Subagents sources updated');
|
||||||
} catch (error) {
|
},
|
||||||
toast.error('Failed to update subagents sources');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[updateSettingsMutation]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
sources,
|
sources,
|
||||||
updateEnabled,
|
updateEnabled,
|
||||||
updateSources,
|
updateSources,
|
||||||
isLoading,
|
isLoading: updateSettingsMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
* Agent definitions in settings JSON are used server-side only.
|
* 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 { useAppStore } from '@/store/app-store';
|
||||||
import type { AgentDefinition } from '@automaker/types';
|
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 SubagentScope = 'global' | 'project';
|
||||||
export type SubagentType = 'filesystem';
|
export type SubagentType = 'filesystem';
|
||||||
@@ -35,51 +37,40 @@ interface FilesystemAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSubagents() {
|
export function useSubagents() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const currentProject = useAppStore((state) => state.currentProject);
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [subagentsWithScope, setSubagentsWithScope] = useState<SubagentWithScope[]>([]);
|
|
||||||
|
|
||||||
// Fetch filesystem agents
|
// Use React Query hook for fetching agents
|
||||||
const fetchFilesystemAgents = useCallback(async () => {
|
const {
|
||||||
setIsLoading(true);
|
data: agents = [],
|
||||||
try {
|
isLoading,
|
||||||
const api = getElectronAPI();
|
refetch,
|
||||||
if (!api.settings) {
|
} = useDiscoveredAgents(currentProject?.path, ['user', 'project']);
|
||||||
console.warn('Settings API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']);
|
|
||||||
|
|
||||||
if (data.success && data.agents) {
|
// Transform agents to SubagentWithScope format
|
||||||
// Transform filesystem agents to SubagentWithScope format
|
const subagentsWithScope = useMemo((): SubagentWithScope[] => {
|
||||||
const agents: SubagentWithScope[] = data.agents.map(
|
return agents.map(({ name, definition, source, filePath }: FilesystemAgent) => ({
|
||||||
({ name, definition, source, filePath }: FilesystemAgent) => ({
|
|
||||||
name,
|
name,
|
||||||
definition,
|
definition,
|
||||||
scope: source === 'user' ? 'global' : 'project',
|
scope: source === 'user' ? 'global' : 'project',
|
||||||
type: 'filesystem' as const,
|
type: 'filesystem' as const,
|
||||||
source,
|
source,
|
||||||
filePath,
|
filePath,
|
||||||
})
|
}));
|
||||||
);
|
}, [agents]);
|
||||||
setSubagentsWithScope(agents);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch filesystem agents:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
// Fetch filesystem agents on mount and when project changes
|
// Refresh function that invalidates the query cache
|
||||||
useEffect(() => {
|
const refreshFilesystemAgents = useCallback(async () => {
|
||||||
fetchFilesystemAgents();
|
await queryClient.invalidateQueries({
|
||||||
}, [fetchFilesystemAgents]);
|
queryKey: queryKeys.settings.agents(currentProject?.path ?? ''),
|
||||||
|
});
|
||||||
|
await refetch();
|
||||||
|
}, [queryClient, currentProject?.path, refetch]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subagentsWithScope,
|
subagentsWithScope,
|
||||||
isLoading,
|
isLoading,
|
||||||
hasProject: !!currentProject,
|
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 { toast } from 'sonner';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
||||||
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
||||||
import { ProviderToggle } from './provider-toggle';
|
import { ProviderToggle } from './provider-toggle';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||||
import type { OpencodeModelId } from '@automaker/types';
|
import type { OpencodeModelId } from '@automaker/types';
|
||||||
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
|
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() {
|
export function OpencodeSettingsTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const {
|
const {
|
||||||
enabledOpencodeModels,
|
enabledOpencodeModels,
|
||||||
opencodeDefaultModel,
|
opencodeDefaultModel,
|
||||||
setOpencodeDefaultModel,
|
setOpencodeDefaultModel,
|
||||||
toggleOpencodeModel,
|
toggleOpencodeModel,
|
||||||
setDynamicOpencodeModels,
|
|
||||||
dynamicOpencodeModels,
|
|
||||||
enabledDynamicModelIds,
|
enabledDynamicModelIds,
|
||||||
toggleDynamicModel,
|
toggleDynamicModel,
|
||||||
cachedOpencodeProviders,
|
|
||||||
setCachedOpencodeProviders,
|
|
||||||
} = useAppStore();
|
} = 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 [isSaving, setIsSaving] = useState(false);
|
||||||
const providerRefreshSignatureRef = useRef<string>('');
|
|
||||||
|
|
||||||
// Phase 1: Load CLI status quickly on mount
|
// React Query hooks for data fetching
|
||||||
useEffect(() => {
|
const {
|
||||||
const checkOpencodeStatus = async () => {
|
data: cliStatusData,
|
||||||
setIsCheckingOpencodeCli(true);
|
isLoading: isCheckingOpencodeCli,
|
||||||
try {
|
refetch: refetchCliStatus,
|
||||||
const api = getElectronAPI();
|
} = useOpencodeCliStatus();
|
||||||
if (api?.setup?.getOpencodeStatus) {
|
|
||||||
const result = await api.setup.getOpencodeStatus();
|
const isCliInstalled = cliStatusData?.installed ?? false;
|
||||||
setCliStatus({
|
|
||||||
success: result.success,
|
const { data: providersData = [], isFetching: isFetchingProviders } = useOpencodeProviders();
|
||||||
status: result.installed ? 'installed' : 'not_installed',
|
|
||||||
method: result.auth?.method,
|
const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels();
|
||||||
version: result.version,
|
|
||||||
path: result.path,
|
// Transform CLI status to the expected format
|
||||||
recommendation: result.recommendation,
|
const cliStatus = useMemo((): SharedCliStatus | null => {
|
||||||
installCommands: result.installCommands,
|
if (!cliStatusData) return null;
|
||||||
});
|
return {
|
||||||
if (result.auth) {
|
success: cliStatusData.success ?? false,
|
||||||
setAuthStatus({
|
status: cliStatusData.installed ? 'installed' : 'not_installed',
|
||||||
authenticated: result.auth.authenticated,
|
method: cliStatusData.auth?.method,
|
||||||
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
|
version: cliStatusData.version,
|
||||||
hasApiKey: result.auth.hasApiKey,
|
path: cliStatusData.path,
|
||||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
recommendation: cliStatusData.recommendation,
|
||||||
hasOAuthToken: result.auth.hasOAuthToken,
|
installCommands: cliStatusData.installCommands,
|
||||||
});
|
|
||||||
}
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
checkOpencodeStatus();
|
}, [cliStatusData]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Phase 2: Load dynamic models and providers in background (only if not cached)
|
// Transform auth status to the expected format
|
||||||
useEffect(() => {
|
const authStatus = useMemo((): OpencodeAuthStatus | null => {
|
||||||
const loadDynamicContent = async () => {
|
if (!cliStatusData?.auth) return null;
|
||||||
const api = getElectronAPI();
|
return {
|
||||||
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
|
authenticated: cliStatusData.auth.authenticated,
|
||||||
|
method: (cliStatusData.auth.method as OpencodeAuthStatus['method']) || 'none',
|
||||||
if (!isInstalled || !api?.setup) return;
|
hasApiKey: cliStatusData.auth.hasApiKey,
|
||||||
|
hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
|
||||||
// Skip if already have cached data
|
hasOAuthToken: cliStatusData.auth.hasOAuthToken,
|
||||||
const needsProviders = cachedOpencodeProviders.length === 0;
|
error: cliStatusData.auth.error,
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
loadDynamicContent();
|
}, [cliStatusData]);
|
||||||
}, [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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
// Refresh all opencode-related queries
|
||||||
const handleRefreshOpencodeCli = useCallback(async () => {
|
const handleRefreshOpencodeCli = useCallback(async () => {
|
||||||
setIsCheckingOpencodeCli(true);
|
await Promise.all([
|
||||||
setIsLoadingDynamicModels(true);
|
queryClient.invalidateQueries({ queryKey: queryKeys.cli.opencode() }),
|
||||||
try {
|
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencodeProviders() }),
|
||||||
const api = getElectronAPI();
|
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencode() }),
|
||||||
if (api?.setup?.getOpencodeStatus) {
|
]);
|
||||||
const result = await api.setup.getOpencodeStatus();
|
await refetchCliStatus();
|
||||||
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');
|
toast.success('OpenCode CLI refreshed');
|
||||||
}
|
}, [queryClient, refetchCliStatus]);
|
||||||
}
|
|
||||||
} 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]);
|
|
||||||
|
|
||||||
const handleDefaultModelChange = useCallback(
|
const handleDefaultModelChange = useCallback(
|
||||||
(model: OpencodeModelId) => {
|
(model: OpencodeModelId) => {
|
||||||
@@ -241,7 +81,7 @@ export function OpencodeSettingsTab() {
|
|||||||
try {
|
try {
|
||||||
setOpencodeDefaultModel(model);
|
setOpencodeDefaultModel(model);
|
||||||
toast.success('Default model updated');
|
toast.success('Default model updated');
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to update default model');
|
toast.error('Failed to update default model');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -255,7 +95,7 @@ export function OpencodeSettingsTab() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
toggleOpencodeModel(model, enabled);
|
toggleOpencodeModel(model, enabled);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to update models');
|
toast.error('Failed to update models');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -269,7 +109,7 @@ export function OpencodeSettingsTab() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
toggleDynamicModel(modelId, enabled);
|
toggleDynamicModel(modelId, enabled);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to update dynamic model');
|
toast.error('Failed to update dynamic model');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -287,7 +127,7 @@ export function OpencodeSettingsTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
|
const isLoadingDynamicModels = isFetchingProviders || isFetchingModels;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -297,7 +137,7 @@ export function OpencodeSettingsTab() {
|
|||||||
<OpencodeCliStatus
|
<OpencodeCliStatus
|
||||||
status={cliStatus}
|
status={cliStatus}
|
||||||
authStatus={authStatus}
|
authStatus={authStatus}
|
||||||
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
providers={providersData as OpenCodeProviderInfo[]}
|
||||||
isChecking={isCheckingOpencodeCli}
|
isChecking={isCheckingOpencodeCli}
|
||||||
onRefresh={handleRefreshOpencodeCli}
|
onRefresh={handleRefreshOpencodeCli}
|
||||||
/>
|
/>
|
||||||
@@ -310,8 +150,8 @@ export function OpencodeSettingsTab() {
|
|||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onDefaultModelChange={handleDefaultModelChange}
|
onDefaultModelChange={handleDefaultModelChange}
|
||||||
onModelToggle={handleModelToggle}
|
onModelToggle={handleModelToggle}
|
||||||
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
providers={providersData as OpenCodeProviderInfo[]}
|
||||||
dynamicModels={dynamicOpencodeModels}
|
dynamicModels={modelsData}
|
||||||
enabledDynamicModelIds={enabledDynamicModelIds}
|
enabledDynamicModelIds={enabledDynamicModelIds}
|
||||||
onDynamicModelToggle={handleDynamicModelToggle}
|
onDynamicModelToggle={handleDynamicModelToggle}
|
||||||
isLoadingDynamicModels={isLoadingDynamicModels}
|
isLoadingDynamicModels={isLoadingDynamicModels}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createElement } from 'react';
|
|||||||
import { SPEC_FILE_WRITE_DELAY, STATUS_CHECK_INTERVAL_MS } from '../constants';
|
import { SPEC_FILE_WRITE_DELAY, STATUS_CHECK_INTERVAL_MS } from '../constants';
|
||||||
import type { FeatureCount } from '../types';
|
import type { FeatureCount } from '../types';
|
||||||
import type { SpecRegenerationEvent } from '@/types/electron';
|
import type { SpecRegenerationEvent } from '@/types/electron';
|
||||||
|
import { useCreateSpec, useRegenerateSpec, useGenerateFeatures } from '@/hooks/mutations';
|
||||||
|
|
||||||
interface UseSpecGenerationOptions {
|
interface UseSpecGenerationOptions {
|
||||||
loadSpec: () => Promise<void>;
|
loadSpec: () => Promise<void>;
|
||||||
@@ -18,6 +19,11 @@ interface UseSpecGenerationOptions {
|
|||||||
export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
|
// React Query mutations
|
||||||
|
const createSpecMutation = useCreateSpec(currentProject?.path ?? '');
|
||||||
|
const regenerateSpecMutation = useRegenerateSpec(currentProject?.path ?? '');
|
||||||
|
const generateFeaturesMutation = useGenerateFeatures(currentProject?.path ?? '');
|
||||||
|
|
||||||
// Dialog visibility state
|
// Dialog visibility state
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||||
@@ -427,33 +433,17 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
logsRef.current = '';
|
logsRef.current = '';
|
||||||
setLogs('');
|
setLogs('');
|
||||||
logger.debug('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures);
|
logger.debug('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures);
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
logger.error('[useSpecGeneration] Spec regeneration not available');
|
|
||||||
setIsCreating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.specRegeneration.create(
|
|
||||||
currentProject.path,
|
|
||||||
projectOverview.trim(),
|
|
||||||
generateFeatures,
|
|
||||||
analyzeProjectOnCreate,
|
|
||||||
generateFeatures ? featureCountOnCreate : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
createSpecMutation.mutate(
|
||||||
const errorMsg = result.error || 'Unknown error';
|
{
|
||||||
logger.error('[useSpecGeneration] Failed to start spec creation:', errorMsg);
|
projectOverview: projectOverview.trim(),
|
||||||
setIsCreating(false);
|
generateFeatures,
|
||||||
setCurrentPhase('error');
|
analyzeProject: analyzeProjectOnCreate,
|
||||||
setErrorMessage(errorMsg);
|
featureCount: generateFeatures ? featureCountOnCreate : undefined,
|
||||||
const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`;
|
},
|
||||||
logsRef.current = errorLog;
|
{
|
||||||
setLogs(errorLog);
|
onError: (error) => {
|
||||||
}
|
const errorMsg = error.message;
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
|
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setCurrentPhase('error');
|
setCurrentPhase('error');
|
||||||
@@ -461,13 +451,16 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
|
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
|
||||||
logsRef.current = errorLog;
|
logsRef.current = errorLog;
|
||||||
setLogs(errorLog);
|
setLogs(errorLog);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}, [
|
}, [
|
||||||
currentProject,
|
currentProject,
|
||||||
projectOverview,
|
projectOverview,
|
||||||
generateFeatures,
|
generateFeatures,
|
||||||
analyzeProjectOnCreate,
|
analyzeProjectOnCreate,
|
||||||
featureCountOnCreate,
|
featureCountOnCreate,
|
||||||
|
createSpecMutation,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleRegenerate = useCallback(async () => {
|
const handleRegenerate = useCallback(async () => {
|
||||||
@@ -483,33 +476,17 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
'[useSpecGeneration] Starting spec regeneration, generateFeatures:',
|
'[useSpecGeneration] Starting spec regeneration, generateFeatures:',
|
||||||
generateFeaturesOnRegenerate
|
generateFeaturesOnRegenerate
|
||||||
);
|
);
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
logger.error('[useSpecGeneration] Spec regeneration not available');
|
|
||||||
setIsRegenerating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.specRegeneration.generate(
|
|
||||||
currentProject.path,
|
|
||||||
projectDefinition.trim(),
|
|
||||||
generateFeaturesOnRegenerate,
|
|
||||||
analyzeProjectOnRegenerate,
|
|
||||||
generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
regenerateSpecMutation.mutate(
|
||||||
const errorMsg = result.error || 'Unknown error';
|
{
|
||||||
logger.error('[useSpecGeneration] Failed to start regeneration:', errorMsg);
|
projectDefinition: projectDefinition.trim(),
|
||||||
setIsRegenerating(false);
|
generateFeatures: generateFeaturesOnRegenerate,
|
||||||
setCurrentPhase('error');
|
analyzeProject: analyzeProjectOnRegenerate,
|
||||||
setErrorMessage(errorMsg);
|
featureCount: generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined,
|
||||||
const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`;
|
},
|
||||||
logsRef.current = errorLog;
|
{
|
||||||
setLogs(errorLog);
|
onError: (error) => {
|
||||||
}
|
const errorMsg = error.message;
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
|
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
|
||||||
setIsRegenerating(false);
|
setIsRegenerating(false);
|
||||||
setCurrentPhase('error');
|
setCurrentPhase('error');
|
||||||
@@ -517,13 +494,16 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
|
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
|
||||||
logsRef.current = errorLog;
|
logsRef.current = errorLog;
|
||||||
setLogs(errorLog);
|
setLogs(errorLog);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}, [
|
}, [
|
||||||
currentProject,
|
currentProject,
|
||||||
projectDefinition,
|
projectDefinition,
|
||||||
generateFeaturesOnRegenerate,
|
generateFeaturesOnRegenerate,
|
||||||
analyzeProjectOnRegenerate,
|
analyzeProjectOnRegenerate,
|
||||||
featureCountOnRegenerate,
|
featureCountOnRegenerate,
|
||||||
|
regenerateSpecMutation,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleGenerateFeatures = useCallback(async () => {
|
const handleGenerateFeatures = useCallback(async () => {
|
||||||
@@ -536,27 +516,10 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
logsRef.current = '';
|
logsRef.current = '';
|
||||||
setLogs('');
|
setLogs('');
|
||||||
logger.debug('[useSpecGeneration] Starting feature generation from existing spec');
|
logger.debug('[useSpecGeneration] Starting feature generation from existing spec');
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
logger.error('[useSpecGeneration] Spec regeneration not available');
|
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.specRegeneration.generateFeatures(currentProject.path);
|
|
||||||
|
|
||||||
if (!result.success) {
|
generateFeaturesMutation.mutate(undefined, {
|
||||||
const errorMsg = result.error || 'Unknown error';
|
onError: (error) => {
|
||||||
logger.error('[useSpecGeneration] Failed to start feature generation:', errorMsg);
|
const errorMsg = error.message;
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
setCurrentPhase('error');
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`;
|
|
||||||
logsRef.current = errorLog;
|
|
||||||
setLogs(errorLog);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
|
logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
|
||||||
setIsGeneratingFeatures(false);
|
setIsGeneratingFeatures(false);
|
||||||
setCurrentPhase('error');
|
setCurrentPhase('error');
|
||||||
@@ -564,8 +527,9 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
|
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
|
||||||
logsRef.current = errorLog;
|
logsRef.current = errorLog;
|
||||||
setLogs(errorLog);
|
setLogs(errorLog);
|
||||||
}
|
},
|
||||||
}, [currentProject]);
|
});
|
||||||
|
}, [currentProject, generateFeaturesMutation]);
|
||||||
|
|
||||||
const handleSync = useCallback(async () => {
|
const handleSync = useCallback(async () => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
|||||||
@@ -1,62 +1,51 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useSpecFile, useSpecRegenerationStatus } from '@/hooks/queries';
|
||||||
const logger = createLogger('SpecLoading');
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
export function useSpecLoading() {
|
export function useSpecLoading() {
|
||||||
const { currentProject, setAppSpec } = useAppStore();
|
const { currentProject, setAppSpec } = useAppStore();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const queryClient = useQueryClient();
|
||||||
const [specExists, setSpecExists] = useState(true);
|
const [specExists, setSpecExists] = useState(true);
|
||||||
const [isGenerationRunning, setIsGenerationRunning] = useState(false);
|
|
||||||
|
|
||||||
const loadSpec = useCallback(async () => {
|
// React Query hooks
|
||||||
if (!currentProject) return;
|
const specFileQuery = useSpecFile(currentProject?.path);
|
||||||
|
const statusQuery = useSpecRegenerationStatus(currentProject?.path);
|
||||||
|
|
||||||
setIsLoading(true);
|
const isGenerationRunning = statusQuery.data?.isRunning ?? false;
|
||||||
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]);
|
|
||||||
|
|
||||||
|
// Update app store and specExists when spec file data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSpec();
|
if (specFileQuery.data && !isGenerationRunning) {
|
||||||
}, [loadSpec]);
|
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 {
|
return {
|
||||||
isLoading,
|
isLoading: specFileQuery.isLoading,
|
||||||
specExists,
|
specExists,
|
||||||
setSpecExists,
|
setSpecExists,
|
||||||
isGenerationRunning,
|
isGenerationRunning,
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useSaveSpec } from '@/hooks/mutations';
|
||||||
const logger = createLogger('SpecSave');
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
|
|
||||||
export function useSpecSave() {
|
export function useSpecSave() {
|
||||||
const { currentProject, appSpec, setAppSpec } = useAppStore();
|
const { currentProject, appSpec, setAppSpec } = useAppStore();
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
// React Query mutation
|
||||||
|
const saveMutation = useSaveSpec(currentProject?.path ?? '');
|
||||||
|
|
||||||
const saveSpec = async () => {
|
const saveSpec = async () => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
|
||||||
setIsSaving(true);
|
saveMutation.mutate(appSpec, {
|
||||||
try {
|
onSuccess: () => setHasChanges(false),
|
||||||
const api = getElectronAPI();
|
});
|
||||||
await api.writeFile(`${currentProject.path}/.automaker/app_spec.txt`, appSpec);
|
|
||||||
setHasChanges(false);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to save spec:', error);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
@@ -31,7 +23,7 @@ export function useSpecSave() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSaving,
|
isSaving: saveMutation.isPending,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
setHasChanges,
|
setHasChanges,
|
||||||
saveSpec,
|
saveSpec,
|
||||||
|
|||||||
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 { useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
const logger = createLogger('BoardBackground');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing board background settings with automatic persistence to server
|
* Hook for managing board background settings with automatic persistence to server.
|
||||||
|
* Uses React Query mutation for server persistence with automatic error handling.
|
||||||
*/
|
*/
|
||||||
export function useBoardBackgroundSettings() {
|
export function useBoardBackgroundSettings() {
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
const httpClient = getHttpApiClient();
|
|
||||||
|
// Get the mutation without a fixed project path - we'll pass it with each call
|
||||||
|
const updateProjectSettings = useUpdateProjectSettings();
|
||||||
|
|
||||||
// Helper to persist settings to server
|
// Helper to persist settings to server
|
||||||
const persistSettings = useCallback(
|
const persistSettings = useCallback(
|
||||||
async (projectPath: string, settingsToUpdate: Record<string, unknown>) => {
|
(projectPath: string, settingsToUpdate: Record<string, unknown>) => {
|
||||||
try {
|
updateProjectSettings.mutate({
|
||||||
const result = await httpClient.settings.updateProject(projectPath, {
|
projectPath,
|
||||||
boardBackground: settingsToUpdate,
|
settings: { boardBackground: settingsToUpdate },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Failed to persist settings:', result.error);
|
|
||||||
toast.error('Failed to save settings');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to persist settings:', error);
|
|
||||||
toast.error('Failed to save settings');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[httpClient]
|
[updateProjectSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get current background settings for a project
|
// Get current background settings for a project
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
* Hook for fetching guided prompts from the backend API
|
* Hook for fetching guided prompts from the backend API
|
||||||
*
|
*
|
||||||
* This hook provides the single source of truth for guided prompts,
|
* This hook provides the single source of truth for guided prompts,
|
||||||
* fetched from the backend /api/ideation/prompts endpoint.
|
* with caching via React Query.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import type { IdeationPrompt, PromptCategory, IdeaCategory } from '@automaker/types';
|
import type { IdeationPrompt, PromptCategory, IdeaCategory } from '@automaker/types';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useIdeationPrompts } from '@/hooks/queries';
|
||||||
|
|
||||||
interface UseGuidedPromptsReturn {
|
interface UseGuidedPromptsReturn {
|
||||||
prompts: IdeationPrompt[];
|
prompts: IdeationPrompt[];
|
||||||
@@ -21,36 +21,10 @@ interface UseGuidedPromptsReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useGuidedPrompts(): UseGuidedPromptsReturn {
|
export function useGuidedPrompts(): UseGuidedPromptsReturn {
|
||||||
const [prompts, setPrompts] = useState<IdeationPrompt[]>([]);
|
const { data, isLoading, error, refetch } = useIdeationPrompts();
|
||||||
const [categories, setCategories] = useState<PromptCategory[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchPrompts = useCallback(async () => {
|
const prompts = data?.prompts ?? [];
|
||||||
setIsLoading(true);
|
const categories = data?.categories ?? [];
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.ideation?.getPrompts();
|
|
||||||
|
|
||||||
if (result?.success) {
|
|
||||||
setPrompts(result.prompts || []);
|
|
||||||
setCategories(result.categories || []);
|
|
||||||
} else {
|
|
||||||
setError(result?.error || 'Failed to fetch prompts');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch guided prompts:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch prompts');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPrompts();
|
|
||||||
}, [fetchPrompts]);
|
|
||||||
|
|
||||||
const getPromptsByCategory = useCallback(
|
const getPromptsByCategory = useCallback(
|
||||||
(category: IdeaCategory): IdeationPrompt[] => {
|
(category: IdeaCategory): IdeationPrompt[] => {
|
||||||
@@ -73,12 +47,23 @@ export function useGuidedPrompts(): UseGuidedPromptsReturn {
|
|||||||
[categories]
|
[categories]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Convert async refetch to match the expected interface
|
||||||
|
const handleRefetch = useCallback(async () => {
|
||||||
|
await refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
// Convert error to string for backward compatibility
|
||||||
|
const errorMessage = useMemo(() => {
|
||||||
|
if (!error) return null;
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prompts,
|
prompts,
|
||||||
categories,
|
categories,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error: errorMessage,
|
||||||
refetch: fetchPrompts,
|
refetch: handleRefetch,
|
||||||
getPromptsByCategory,
|
getPromptsByCategory,
|
||||||
getPromptById,
|
getPromptById,
|
||||||
getCategoryById,
|
getCategoryById,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { useProjectSettings } from '@/hooks/queries';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that loads project settings from the server when the current project changes.
|
* Hook that loads project settings from the server when the current project changes.
|
||||||
* This ensures that settings like board backgrounds are properly restored when
|
* This ensures that settings like board backgrounds are properly restored when
|
||||||
* switching between projects or restarting the app.
|
* switching between projects or restarting the app.
|
||||||
|
*
|
||||||
|
* Uses React Query for data fetching with automatic caching.
|
||||||
*/
|
*/
|
||||||
export function useProjectSettingsLoader() {
|
export function useProjectSettingsLoader() {
|
||||||
const currentProject = useAppStore((state) => state.currentProject);
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
@@ -25,40 +27,33 @@ export function useProjectSettingsLoader() {
|
|||||||
);
|
);
|
||||||
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
|
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
|
||||||
|
|
||||||
const loadingRef = useRef<string | null>(null);
|
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
|
||||||
const currentProjectRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
|
// Fetch project settings with React Query
|
||||||
|
const { data: settings, dataUpdatedAt } = useProjectSettings(currentProject?.path);
|
||||||
|
|
||||||
|
// Apply settings when data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentProjectRef.current = currentProject?.path ?? null;
|
if (!currentProject?.path || !settings) {
|
||||||
|
|
||||||
if (!currentProject?.path) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent loading the same project multiple times
|
// Prevent applying the same settings multiple times
|
||||||
if (loadingRef.current === currentProject.path) {
|
if (
|
||||||
|
appliedProjectRef.current?.path === currentProject.path &&
|
||||||
|
appliedProjectRef.current?.dataUpdatedAt === dataUpdatedAt
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadingRef.current = currentProject.path;
|
appliedProjectRef.current = { path: currentProject.path, dataUpdatedAt };
|
||||||
const requestedProjectPath = currentProject.path;
|
const projectPath = currentProject.path;
|
||||||
|
|
||||||
const loadProjectSettings = async () => {
|
const bg = settings.boardBackground;
|
||||||
try {
|
|
||||||
const httpClient = getHttpApiClient();
|
|
||||||
const result = await httpClient.settings.getProject(requestedProjectPath);
|
|
||||||
|
|
||||||
// Race condition protection: ignore stale results if project changed
|
|
||||||
if (currentProjectRef.current !== requestedProjectPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success && result.settings) {
|
|
||||||
const bg = result.settings.boardBackground;
|
|
||||||
|
|
||||||
// Apply boardBackground if present
|
// Apply boardBackground if present
|
||||||
if (bg?.imagePath) {
|
if (bg?.imagePath) {
|
||||||
setBoardBackground(requestedProjectPath, bg.imagePath);
|
setBoardBackground(projectPath, bg.imagePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings map for cleaner iteration
|
// Settings map for cleaner iteration
|
||||||
@@ -76,67 +71,60 @@ export function useProjectSettingsLoader() {
|
|||||||
for (const [key, setter] of Object.entries(settingsMap)) {
|
for (const [key, setter] of Object.entries(settingsMap)) {
|
||||||
const value = bg?.[key as keyof typeof bg];
|
const value = bg?.[key as keyof typeof bg];
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
(setter as (path: string, val: typeof value) => void)(requestedProjectPath, value);
|
(setter as (path: string, val: typeof value) => void)(projectPath, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply worktreePanelVisible if present
|
// Apply worktreePanelVisible if present
|
||||||
if (result.settings.worktreePanelVisible !== undefined) {
|
if (settings.worktreePanelVisible !== undefined) {
|
||||||
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
|
setWorktreePanelVisible(projectPath, settings.worktreePanelVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply showInitScriptIndicator if present
|
// Apply showInitScriptIndicator if present
|
||||||
if (result.settings.showInitScriptIndicator !== undefined) {
|
if (settings.showInitScriptIndicator !== undefined) {
|
||||||
setShowInitScriptIndicator(
|
setShowInitScriptIndicator(projectPath, settings.showInitScriptIndicator);
|
||||||
requestedProjectPath,
|
|
||||||
result.settings.showInitScriptIndicator
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply defaultDeleteBranch if present
|
// Apply defaultDeleteBranchWithWorktree if present
|
||||||
if (result.settings.defaultDeleteBranchWithWorktree !== undefined) {
|
if (settings.defaultDeleteBranchWithWorktree !== undefined) {
|
||||||
setDefaultDeleteBranch(
|
setDefaultDeleteBranch(projectPath, settings.defaultDeleteBranchWithWorktree);
|
||||||
requestedProjectPath,
|
|
||||||
result.settings.defaultDeleteBranchWithWorktree
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply autoDismissInitScriptIndicator if present
|
// Apply autoDismissInitScriptIndicator if present
|
||||||
if (result.settings.autoDismissInitScriptIndicator !== undefined) {
|
if (settings.autoDismissInitScriptIndicator !== undefined) {
|
||||||
setAutoDismissInitScriptIndicator(
|
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
|
||||||
requestedProjectPath,
|
|
||||||
result.settings.autoDismissInitScriptIndicator
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply activeClaudeApiProfileId if present
|
// Apply activeClaudeApiProfileId if present
|
||||||
// This is stored directly on the project, so we need to update the currentProject
|
if (settings.activeClaudeApiProfileId !== undefined) {
|
||||||
// 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;
|
const updatedProject = useAppStore.getState().currentProject;
|
||||||
if (
|
if (
|
||||||
updatedProject &&
|
updatedProject &&
|
||||||
updatedProject.path === requestedProjectPath &&
|
updatedProject.path === projectPath &&
|
||||||
updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId
|
updatedProject.activeClaudeApiProfileId !== settings.activeClaudeApiProfileId
|
||||||
) {
|
) {
|
||||||
setCurrentProject({
|
setCurrentProject({
|
||||||
...updatedProject,
|
...updatedProject,
|
||||||
activeClaudeApiProfileId,
|
activeClaudeApiProfileId: settings.activeClaudeApiProfileId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}, [
|
||||||
} catch (error) {
|
currentProject?.path,
|
||||||
console.error('Failed to load project settings:', error);
|
settings,
|
||||||
// Don't show error toast - just log it
|
dataUpdatedAt,
|
||||||
}
|
setBoardBackground,
|
||||||
};
|
setCardOpacity,
|
||||||
|
setColumnOpacity,
|
||||||
loadProjectSettings();
|
setColumnBorderEnabled,
|
||||||
}, [currentProject?.path]);
|
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,
|
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
|
||||||
lastProjectDir: settings.lastProjectDir ?? '',
|
lastProjectDir: settings.lastProjectDir ?? '',
|
||||||
recentFolders: settings.recentFolders ?? [],
|
recentFolders: settings.recentFolders ?? [],
|
||||||
// Event hooks
|
|
||||||
eventHooks: settings.eventHooks ?? [],
|
|
||||||
// Terminal font (nested in terminalState)
|
// Terminal font (nested in terminalState)
|
||||||
...(settings.terminalFontFamily && {
|
...(settings.terminalFontFamily && {
|
||||||
terminalState: {
|
terminalState: {
|
||||||
@@ -810,7 +808,6 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
lastProjectDir: state.lastProjectDir,
|
lastProjectDir: state.lastProjectDir,
|
||||||
recentFolders: state.recentFolders,
|
recentFolders: state.recentFolders,
|
||||||
terminalFontFamily: state.terminalState.fontFamily,
|
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 { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
|
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 { createLogger } from '@automaker/utils/logger';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import { ProjectSwitcher } from '@/components/layout/project-switcher';
|
import { ProjectSwitcher } from '@/components/layout/project-switcher';
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
signalMigrationComplete,
|
signalMigrationComplete,
|
||||||
performSettingsMigration,
|
performSettingsMigration,
|
||||||
} from '@/hooks/use-settings-migration';
|
} from '@/hooks/use-settings-migration';
|
||||||
|
import { queryClient } from '@/lib/query-client';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||||
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
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';
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
const logger = createLogger('RootLayout');
|
const logger = createLogger('RootLayout');
|
||||||
|
const SHOW_QUERY_DEVTOOLS = import.meta.env.DEV;
|
||||||
const SERVER_READY_MAX_ATTEMPTS = 8;
|
const SERVER_READY_MAX_ATTEMPTS = 8;
|
||||||
const SERVER_READY_BACKOFF_BASE_MS = 250;
|
const SERVER_READY_BACKOFF_BASE_MS = 250;
|
||||||
const SERVER_READY_MAX_DELAY_MS = 1500;
|
const SERVER_READY_MAX_DELAY_MS = 1500;
|
||||||
@@ -892,9 +896,14 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
return (
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<FileBrowserProvider>
|
<FileBrowserProvider>
|
||||||
<RootLayoutContent />
|
<RootLayoutContent />
|
||||||
</FileBrowserProvider>
|
</FileBrowserProvider>
|
||||||
|
{SHOW_QUERY_DEVTOOLS ? (
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||||
|
) : null}
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
:root {
|
:root {
|
||||||
/* Default to light mode */
|
/* Default to light mode */
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
|
--perf-contain-intrinsic-size: 500px;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
@@ -1120,3 +1121,9 @@
|
|||||||
animation: none;
|
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,
|
resolveDependencies,
|
||||||
areDependenciesSatisfied,
|
areDependenciesSatisfied,
|
||||||
getBlockingDependencies,
|
getBlockingDependencies,
|
||||||
|
createFeatureMap,
|
||||||
|
getBlockingDependenciesFromMap,
|
||||||
wouldCreateCircularDependency,
|
wouldCreateCircularDependency,
|
||||||
dependencyExists,
|
dependencyExists,
|
||||||
getAncestors,
|
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.
|
* 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.
|
* When we say "targetId depends on sourceId", we add sourceId to targetId.dependencies.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
resolveDependencies,
|
resolveDependencies,
|
||||||
areDependenciesSatisfied,
|
areDependenciesSatisfied,
|
||||||
getBlockingDependencies,
|
getBlockingDependencies,
|
||||||
|
createFeatureMap,
|
||||||
|
getBlockingDependenciesFromMap,
|
||||||
wouldCreateCircularDependency,
|
wouldCreateCircularDependency,
|
||||||
dependencyExists,
|
dependencyExists,
|
||||||
} from '../src/resolver';
|
} 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', () => {
|
describe('wouldCreateCircularDependency', () => {
|
||||||
it('should return false for features with no existing dependencies', () => {
|
it('should return false for features with no existing dependencies', () => {
|
||||||
const features = [createFeature('A'), createFeature('B')];
|
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-switch": "1.2.6",
|
||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@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",
|
"@tanstack/react-router": "1.141.6",
|
||||||
"@uiw/react-codemirror": "4.25.4",
|
"@uiw/react-codemirror": "4.25.4",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@xterm/addon-fit": "0.10.0",
|
||||||
@@ -5594,9 +5595,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.12",
|
"version": "5.90.19",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz",
|
||||||
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
|
"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",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5604,12 +5615,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.90.12",
|
"version": "5.90.19",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz",
|
||||||
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
|
"integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.12"
|
"@tanstack/query-core": "5.90.19"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5619,6 +5630,23 @@
|
|||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@tanstack/react-router": {
|
||||||
"version": "1.141.6",
|
"version": "1.141.6",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user