From d4b7a0c57d0a42d745dd5e40016365581b54be93 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Wed, 4 Feb 2026 10:16:11 +0530 Subject: [PATCH] feat(ui): add board refresh and stale-state polling --- apps/ui/src/components/views/board-view.tsx | 58 ++++++++++++++ .../views/board-view/board-header.tsx | 33 +++++++- apps/ui/src/hooks/queries/use-features.ts | 4 +- .../src/hooks/queries/use-running-agents.ts | 5 +- apps/ui/src/hooks/queries/use-worktrees.ts | 3 + apps/ui/src/hooks/use-auto-mode.ts | 75 +++++++++++-------- 6 files changed, 144 insertions(+), 34 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 6ae7c9a0..d8be006d 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -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} /> diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 77a272c9..0db3dd48 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -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; // 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 (
@@ -127,6 +142,22 @@ export function BoardHeader({
+ {isMounted && !isTablet && ( + + + + + Refresh board state from server + + )} {/* Usage Popover - show if either provider is authenticated, only on desktop */} {isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && } diff --git a/apps/ui/src/hooks/queries/use-features.ts b/apps/ui/src/hooks/queries/use-features.ts index 334ec3d4..d4a88421 100644 --- a/apps/ui/src/hooks/queries/use-features.ts +++ b/apps/ui/src/hooks/queries/use-features.ts @@ -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, }); diff --git a/apps/ui/src/hooks/queries/use-running-agents.ts b/apps/ui/src/hooks/queries/use-running-agents.ts index 7aabc1d8..81cbfede 100644 --- a/apps/ui/src/hooks/queries/use-running-agents.ts +++ b/apps/ui/src/hooks/queries/use-running-agents.ts @@ -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, }); diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts index 8012d1cb..60fdcca9 100644 --- a/apps/ui/src/hooks/queries/use-worktrees.ts +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -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, }); diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index 29fe1fe8..cb683417 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -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, }; }