refactor(ui): migrate usage popovers and running agents to React Query

- Migrate claude-usage-popover to useClaudeUsage query with polling
- Migrate codex-usage-popover to useCodexUsage query with polling
- Migrate usage-popover to React Query hooks
- Migrate running-agents-view to useRunningAgents query
- Replace manual polling intervals with refetchInterval
- Remove manual loading/error state management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-15 16:22:17 +01:00
parent 20caa424fc
commit 5fe7bcd378
4 changed files with 134 additions and 415 deletions

View File

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

View File

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

View File

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

View File

@@ -1,95 +1,47 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
/**
* 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 { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import type { RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useNavigate } from '@tanstack/react-router';
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
const logger = createLogger('RunningAgentsView');
import { useRunningAgents } from '@/hooks/queries';
import { useStopFeature } from '@/hooks/mutations';
export function RunningAgentsView() {
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
const { setCurrentProject, projects } = useAppStore();
const navigate = useNavigate();
const fetchRunningAgents = useCallback(async () => {
try {
const api = getElectronAPI();
if (api.runningAgents) {
const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) {
setRunningAgents(result.runningAgents);
}
}
} catch (error) {
logger.error('Error fetching running agents:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// Use React Query for running agents with auto-refresh
const { data, isLoading, isFetching, refetch } = useRunningAgents();
// Initial fetch
useEffect(() => {
fetchRunningAgents();
}, [fetchRunningAgents]);
const runningAgents = data?.agents ?? [];
// Auto-refresh every 2 seconds
useEffect(() => {
const interval = setInterval(() => {
fetchRunningAgents();
}, 2000);
return () => clearInterval(interval);
}, [fetchRunningAgents]);
// Subscribe to auto-mode events to update in real-time
useEffect(() => {
const api = getElectronAPI();
if (!api.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event) => {
// When a feature completes or errors, refresh the list
if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
fetchRunningAgents();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgents]);
// Use mutation for stopping features
const stopFeature = useStopFeature();
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchRunningAgents();
}, [fetchRunningAgents]);
refetch();
}, [refetch]);
const handleStopAgent = useCallback(
async (featureId: string) => {
try {
const api = getElectronAPI();
if (api.autoMode) {
await api.autoMode.stopFeature(featureId);
// Refresh list after stopping
fetchRunningAgents();
}
} catch (error) {
logger.error('Error stopping agent:', error);
}
(featureId: string) => {
stopFeature.mutate(featureId);
},
[fetchRunningAgents]
[stopFeature]
);
const handleNavigateToProject = useCallback(
(agent: RunningAgent) => {
// Find the project by path
const project = projects.find((p) => p.path === agent.projectPath);
if (project) {
setCurrentProject(project);
@@ -103,7 +55,7 @@ export function RunningAgentsView() {
setSelectedAgent(agent);
}, []);
if (loading) {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -128,8 +80,8 @@ export function RunningAgentsView() {
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
Refresh
</Button>
</div>
@@ -217,6 +169,7 @@ export function RunningAgentsView() {
variant="destructive"
size="sm"
onClick={() => handleStopAgent(agent.featureId)}
disabled={stopFeature.isPending}
>
<Square className="h-3.5 w-3.5 mr-1.5" />
Stop