feat(ui): add board refresh and stale-state polling

This commit is contained in:
DhanushSantosh
2026-02-04 10:16:11 +05:30
parent 0b6e84ec6e
commit d4b7a0c57d
6 changed files with 144 additions and 34 deletions

View File

@@ -437,6 +437,63 @@ export function BoardView() {
// Auto mode hook - pass current worktree to get worktree-specific state // Auto mode hook - pass current worktree to get worktree-specific state
// Must be after selectedWorktree is defined // Must be after selectedWorktree is defined
const autoMode = useAutoMode(selectedWorktree); const autoMode = useAutoMode(selectedWorktree);
const refreshBoardState = useCallback(async () => {
if (!currentProject) return;
const projectPath = currentProject.path;
const beforeFeatures = (
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
)?.length;
const beforeWorktrees = (
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
| { worktrees?: unknown[] }
| undefined
)?.worktrees?.length;
const beforeRunningAgents = (
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
)?.count;
const beforeAutoModeRunning = autoMode.isRunning;
try {
await Promise.all([
queryClient.refetchQueries({ queryKey: queryKeys.features.all(projectPath) }),
queryClient.refetchQueries({ queryKey: queryKeys.runningAgents.all() }),
queryClient.refetchQueries({ queryKey: queryKeys.worktrees.all(projectPath) }),
autoMode.refreshStatus(),
]);
const afterFeatures = (
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
)?.length;
const afterWorktrees = (
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
| { worktrees?: unknown[] }
| undefined
)?.worktrees?.length;
const afterRunningAgents = (
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
)?.count;
const afterAutoModeRunning = autoMode.isRunning;
if (
beforeFeatures !== afterFeatures ||
beforeWorktrees !== afterWorktrees ||
beforeRunningAgents !== afterRunningAgents ||
beforeAutoModeRunning !== afterAutoModeRunning
) {
logger.info('[Board] Refresh detected state mismatch', {
features: { before: beforeFeatures, after: afterFeatures },
worktrees: { before: beforeWorktrees, after: afterWorktrees },
runningAgents: { before: beforeRunningAgents, after: afterRunningAgents },
autoModeRunning: { before: beforeAutoModeRunning, after: afterAutoModeRunning },
});
}
} catch (error) {
logger.error('[Board] Failed to refresh board state:', error);
toast.error('Failed to refresh board state');
}
}, [autoMode, currentProject, queryClient]);
// Get runningTasks from the hook (scoped to current project/worktree) // Get runningTasks from the hook (scoped to current project/worktree)
const runningAutoTasks = autoMode.runningTasks; const runningAutoTasks = autoMode.runningTasks;
// Get worktree-specific maxConcurrency from the hook // Get worktree-specific maxConcurrency from the hook
@@ -1321,6 +1378,7 @@ export function BoardView() {
isCreatingSpec={isCreatingSpec} isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath} creatingSpecProjectPath={creatingSpecProjectPath}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)} onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onRefreshBoard={refreshBoardState}
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
/> />

View File

@@ -1,7 +1,9 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Wand2, GitBranch, ClipboardCheck, RefreshCw } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover'; import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
@@ -35,6 +37,7 @@ interface BoardHeaderProps {
creatingSpecProjectPath?: string; creatingSpecProjectPath?: string;
// Board controls props // Board controls props
onShowBoardBackground: () => void; onShowBoardBackground: () => void;
onRefreshBoard: () => Promise<void>;
// View toggle props // View toggle props
viewMode: ViewMode; viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void; onViewModeChange: (mode: ViewMode) => void;
@@ -60,6 +63,7 @@ export function BoardHeader({
isCreatingSpec, isCreatingSpec,
creatingSpecProjectPath, creatingSpecProjectPath,
onShowBoardBackground, onShowBoardBackground,
onRefreshBoard,
viewMode, viewMode,
onViewModeChange, onViewModeChange,
}: BoardHeaderProps) { }: BoardHeaderProps) {
@@ -110,9 +114,20 @@ export function BoardHeader({
// State for mobile actions panel // State for mobile actions panel
const [showActionsPanel, setShowActionsPanel] = useState(false); const [showActionsPanel, setShowActionsPanel] = useState(false);
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
const isTablet = useIsTablet(); const isTablet = useIsTablet();
const handleRefreshBoard = useCallback(async () => {
if (isRefreshingBoard) return;
setIsRefreshingBoard(true);
try {
await onRefreshBoard();
} finally {
setIsRefreshingBoard(false);
}
}, [isRefreshingBoard, onRefreshBoard]);
return ( return (
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md"> <div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -127,6 +142,22 @@ export function BoardHeader({
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} /> <BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
{isMounted && !isTablet && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
onClick={handleRefreshBoard}
disabled={isRefreshingBoard}
aria-label="Refresh board state from server"
>
<RefreshCw className={isRefreshingBoard ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
</Tooltip>
)}
{/* Usage Popover - show if either provider is authenticated, only on desktop */} {/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />} {isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}

View File

@@ -10,11 +10,12 @@ import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys'; import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client'; import { STALE_TIMES } from '@/lib/query-client';
import { getGlobalEventsRecent } from '@/hooks/use-event-recency'; import { createSmartPollingInterval, getGlobalEventsRecent } from '@/hooks/use-event-recency';
import type { Feature } from '@/store/app-store'; import type { Feature } from '@/store/app-store';
const FEATURES_REFETCH_ON_FOCUS = false; const FEATURES_REFETCH_ON_FOCUS = false;
const FEATURES_REFETCH_ON_RECONNECT = false; const FEATURES_REFETCH_ON_RECONNECT = false;
const FEATURES_POLLING_INTERVAL = 30000;
/** Default polling interval for agent output when WebSocket is inactive */ /** Default polling interval for agent output when WebSocket is inactive */
const AGENT_OUTPUT_POLLING_INTERVAL = 5000; const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
@@ -43,6 +44,7 @@ export function useFeatures(projectPath: string | undefined) {
}, },
enabled: !!projectPath, enabled: !!projectPath,
staleTime: STALE_TIMES.FEATURES, staleTime: STALE_TIMES.FEATURES,
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
}); });

View File

@@ -9,9 +9,11 @@ import { useQuery } from '@tanstack/react-query';
import { getElectronAPI, type RunningAgent } from '@/lib/electron'; import { getElectronAPI, type RunningAgent } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys'; import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client'; import { STALE_TIMES } from '@/lib/query-client';
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
const RUNNING_AGENTS_REFETCH_ON_FOCUS = false; const RUNNING_AGENTS_REFETCH_ON_FOCUS = false;
const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false; const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false;
const RUNNING_AGENTS_POLLING_INTERVAL = 30000;
interface RunningAgentsResult { interface RunningAgentsResult {
agents: RunningAgent[]; agents: RunningAgent[];
@@ -47,8 +49,7 @@ export function useRunningAgents() {
}; };
}, },
staleTime: STALE_TIMES.RUNNING_AGENTS, staleTime: STALE_TIMES.RUNNING_AGENTS,
// Note: Don't use refetchInterval here - rely on WebSocket invalidation refetchInterval: createSmartPollingInterval(RUNNING_AGENTS_POLLING_INTERVAL),
// for real-time updates instead of polling
refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS, refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS,
refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT, refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT,
}); });

View File

@@ -8,9 +8,11 @@ import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys'; import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client'; import { STALE_TIMES } from '@/lib/query-client';
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
const WORKTREE_REFETCH_ON_FOCUS = false; const WORKTREE_REFETCH_ON_FOCUS = false;
const WORKTREE_REFETCH_ON_RECONNECT = false; const WORKTREE_REFETCH_ON_RECONNECT = false;
const WORKTREES_POLLING_INTERVAL = 30000;
interface WorktreeInfo { interface WorktreeInfo {
path: string; path: string;
@@ -65,6 +67,7 @@ export function useWorktrees(projectPath: string | undefined, includeDetails = t
}, },
enabled: !!projectPath, enabled: !!projectPath,
staleTime: STALE_TIMES.WORKTREES, staleTime: STALE_TIMES.WORKTREES,
refetchInterval: createSmartPollingInterval(WORKTREES_POLLING_INTERVAL),
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
}); });

View File

@@ -6,10 +6,12 @@ import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron'; import type { AutoModeEvent } from '@/types/electron';
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types'; import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
const logger = createLogger('AutoMode'); const logger = createLogger('AutoMode');
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey'; const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
const AUTO_MODE_POLLING_INTERVAL = 30000;
/** /**
* Generate a worktree key for session storage * Generate a worktree key for session storage
@@ -140,42 +142,54 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Check if we can start a new task based on concurrency limit // Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency; const canStartNewTask = runningAutoTasks.length < maxConcurrency;
const refreshStatus = useCallback(async () => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const result = await api.autoMode.status(currentProject.path, branchName);
if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning;
if (backendIsRunning !== isAutoModeRunning) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
setAutoModeRunning(
currentProject.id,
branchName,
backendIsRunning,
result.maxConcurrency,
result.runningFeatures
);
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
}
}
} catch (error) {
logger.error('Error syncing auto mode state with backend:', error);
}
}, [branchName, currentProject, isAutoModeRunning, setAutoModeRunning]);
// On mount, query backend for current auto loop status and sync UI state. // On mount, query backend for current auto loop status and sync UI state.
// This handles cases where the backend is still running after a page refresh. // This handles cases where the backend is still running after a page refresh.
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
// Periodic polling fallback when WebSocket events are stale.
useEffect(() => { useEffect(() => {
if (!currentProject) return; if (!currentProject) return;
const syncWithBackend = async () => { const interval = setInterval(() => {
try { if (getGlobalEventsRecent()) return;
const api = getElectronAPI(); void refreshStatus();
if (!api?.autoMode?.status) return; }, AUTO_MODE_POLLING_INTERVAL);
const result = await api.autoMode.status(currentProject.path, branchName); return () => clearInterval(interval);
if (result.success && result.isAutoLoopRunning !== undefined) { }, [currentProject, refreshStatus]);
const backendIsRunning = result.isAutoLoopRunning;
if (backendIsRunning !== isAutoModeRunning) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
setAutoModeRunning(
currentProject.id,
branchName,
backendIsRunning,
result.maxConcurrency,
result.runningFeatures
);
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
}
}
} catch (error) {
logger.error('Error syncing auto mode state with backend:', error);
}
};
syncWithBackend();
}, [currentProject, branchName, setAutoModeRunning]);
// Handle auto mode events - listen globally for all projects/worktrees // Handle auto mode events - listen globally for all projects/worktrees
useEffect(() => { useEffect(() => {
@@ -672,5 +686,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
start, start,
stop, stop,
stopFeature, stopFeature,
refreshStatus,
}; };
} }