mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
feat(ui): add board refresh and stale-state polling
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user