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
// Must be after selectedWorktree is defined
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)
const runningAutoTasks = autoMode.runningTasks;
// Get worktree-specific maxConcurrency from the hook
@@ -1321,6 +1378,7 @@ export function BoardView() {
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onRefreshBoard={refreshBoardState}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>

View File

@@ -1,7 +1,9 @@
import { useCallback, useState } from 'react';
import { Switch } from '@/components/ui/switch';
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 { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
@@ -35,6 +37,7 @@ interface BoardHeaderProps {
creatingSpecProjectPath?: string;
// Board controls props
onShowBoardBackground: () => void;
onRefreshBoard: () => Promise<void>;
// View toggle props
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
@@ -60,6 +63,7 @@ export function BoardHeader({
isCreatingSpec,
creatingSpecProjectPath,
onShowBoardBackground,
onRefreshBoard,
viewMode,
onViewModeChange,
}: BoardHeaderProps) {
@@ -110,9 +114,20 @@ export function BoardHeader({
// State for mobile actions panel
const [showActionsPanel, setShowActionsPanel] = useState(false);
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
const isTablet = useIsTablet();
const handleRefreshBoard = useCallback(async () => {
if (isRefreshingBoard) return;
setIsRefreshingBoard(true);
try {
await onRefreshBoard();
} finally {
setIsRefreshingBoard(false);
}
}, [isRefreshingBoard, onRefreshBoard]);
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 gap-4">
@@ -127,6 +142,22 @@ export function BoardHeader({
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
</div>
<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 */}
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}

View File

@@ -10,11 +10,12 @@ 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 { getGlobalEventsRecent } from '@/hooks/use-event-recency';
import { createSmartPollingInterval, getGlobalEventsRecent } from '@/hooks/use-event-recency';
import type { Feature } from '@/store/app-store';
const FEATURES_REFETCH_ON_FOCUS = false;
const FEATURES_REFETCH_ON_RECONNECT = false;
const FEATURES_POLLING_INTERVAL = 30000;
/** Default polling interval for agent output when WebSocket is inactive */
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
@@ -43,6 +44,7 @@ export function useFeatures(projectPath: string | undefined) {
},
enabled: !!projectPath,
staleTime: STALE_TIMES.FEATURES,
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
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 { queryKeys } from '@/lib/query-keys';
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_RECONNECT = false;
const RUNNING_AGENTS_POLLING_INTERVAL = 30000;
interface RunningAgentsResult {
agents: RunningAgent[];
@@ -47,8 +49,7 @@ export function useRunningAgents() {
};
},
staleTime: STALE_TIMES.RUNNING_AGENTS,
// Note: Don't use refetchInterval here - rely on WebSocket invalidation
// for real-time updates instead of polling
refetchInterval: createSmartPollingInterval(RUNNING_AGENTS_POLLING_INTERVAL),
refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS,
refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT,
});

View File

@@ -8,9 +8,11 @@ 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 { createSmartPollingInterval } from '@/hooks/use-event-recency';
const WORKTREE_REFETCH_ON_FOCUS = false;
const WORKTREE_REFETCH_ON_RECONNECT = false;
const WORKTREES_POLLING_INTERVAL = 30000;
interface WorktreeInfo {
path: string;
@@ -65,6 +67,7 @@ export function useWorktrees(projectPath: string | undefined, includeDetails = t
},
enabled: !!projectPath,
staleTime: STALE_TIMES.WORKTREES,
refetchInterval: createSmartPollingInterval(WORKTREES_POLLING_INTERVAL),
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
});

View File

@@ -6,10 +6,12 @@ import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
const logger = createLogger('AutoMode');
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
const AUTO_MODE_POLLING_INTERVAL = 30000;
/**
* 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
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.
// 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(() => {
if (!currentProject) return;
const syncWithBackend = async () => {
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const interval = setInterval(() => {
if (getGlobalEventsRecent()) return;
void refreshStatus();
}, AUTO_MODE_POLLING_INTERVAL);
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);
}
};
syncWithBackend();
}, [currentProject, branchName, setAutoModeRunning]);
return () => clearInterval(interval);
}, [currentProject, refreshStatus]);
// Handle auto mode events - listen globally for all projects/worktrees
useEffect(() => {
@@ -672,5 +686,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
start,
stop,
stopFeature,
refreshStatus,
};
}