5 Commits

Author SHA1 Message Date
DhanushSantosh
63cae19aec feat(enhance): add project context to prompt enhancements 2026-02-04 10:16:35 +05:30
DhanushSantosh
c9e721bda7 fix(cursor): detect Windows native CLI installs 2026-02-04 10:16:19 +05:30
DhanushSantosh
d4b7a0c57d feat(ui): add board refresh and stale-state polling 2026-02-04 10:16:11 +05:30
DhanushSantosh
0b6e84ec6e fix(ui): wrap long branch names in create PR dialog 2026-02-04 10:15:58 +05:30
DhanushSantosh
e9c2afcc02 docs: warn about Docker UID/GID mismatch 2026-02-04 09:50:48 +05:30
10 changed files with 458 additions and 67 deletions

View File

@@ -288,6 +288,31 @@ services:
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
> **⚠️ Important: Linux/WSL Users**
>
> The container runs as UID 1001 by default. If your host user has a different UID (common on Linux/WSL where the first user is UID 1000), you must create a `.env` file to match your host user:
>
> ```bash
> # Check your UID/GID
> id -u # outputs your UID (e.g., 1000)
> id -g # outputs your GID (e.g., 1000)
> ```
>
> Create a `.env` file in the automaker directory:
>
> ```
> UID=1000
> GID=1000
> ```
>
> Then rebuild the images:
>
> ```bash
> docker compose build
> ```
>
> Without this, files written by the container will be inaccessible to your host user.
##### GitHub CLI Authentication (For Git Push/PR Operations)
To enable git push and GitHub CLI operations inside the container:

View File

@@ -14,6 +14,7 @@ import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { findCliInWsl, isWslAvailable } from '@automaker/platform';
import {
CliProvider,
type CliSpawnConfig,
@@ -286,15 +287,113 @@ export class CursorProvider extends CliProvider {
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'wsl', // cursor-agent requires WSL on Windows
windowsStrategy: 'direct',
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
'/usr/local/bin/cursor-agent',
],
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
// Windows paths are not used - we check for WSL installation instead
win32: [],
win32: [
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor-agent.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor-agent.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'cursor.exe'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'npm',
'cursor-agent.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'npm',
'cursor.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'.npm-global',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'.npm-global',
'bin',
'cursor.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'pnpm',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'pnpm',
'cursor.cmd'
),
],
},
};
}
@@ -487,6 +586,92 @@ export class CursorProvider extends CliProvider {
* 2. Cursor IDE with 'cursor agent' subcommand support
*/
protected detectCli(): CliDetectionResult {
if (process.platform === 'win32') {
const findInPath = (command: string): string | null => {
try {
const result = execSync(`where ${command}`, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
})
.trim()
.split(/\r?\n/)[0];
if (result && fs.existsSync(result)) {
return result;
}
} catch {
// Not in PATH
}
return null;
};
const isCursorAgentBinary = (cliPath: string) =>
cliPath.toLowerCase().includes('cursor-agent');
const supportsCursorAgentSubcommand = (cliPath: string) => {
try {
execSync(`"${cliPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
windowsHide: true,
});
return true;
} catch {
return false;
}
};
const pathResult = findInPath('cursor-agent') || findInPath('cursor');
if (pathResult) {
if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
return {
cliPath: pathResult,
useWsl: false,
strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
};
}
}
const config = this.getSpawnConfig();
for (const candidate of config.commonPaths.win32 || []) {
const resolved = candidate;
if (!fs.existsSync(resolved)) {
continue;
}
if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
return {
cliPath: resolved,
useWsl: false,
strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
};
}
}
const wslLogger = (msg: string) => logger.debug(msg);
if (isWslAvailable({ logger: wslLogger })) {
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
if (wslResult) {
logger.debug(
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
);
return {
cliPath: 'wsl.exe',
useWsl: true,
wslCliPath: wslResult.wslPath,
wslDistribution: wslResult.distribution,
strategy: 'wsl',
};
}
}
logger.debug('cursor-agent not found on Windows');
return { cliPath: null, useWsl: false, strategy: 'direct' };
}
// First try standard detection (PATH, common paths, WSL)
const result = super.detectCli();
if (result.cliPath) {
@@ -495,7 +680,7 @@ export class CursorProvider extends CliProvider {
// Cursor-specific: Check versions directory for any installed version
// This handles cases where cursor-agent is installed but not in PATH
if (process.platform !== 'win32' && fs.existsSync(CursorProvider.VERSIONS_DIR)) {
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
try {
const versions = fs
.readdirSync(CursorProvider.VERSIONS_DIR)
@@ -521,33 +706,31 @@ export class CursorProvider extends CliProvider {
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
// The Cursor IDE includes the agent as a subcommand: cursor agent
if (process.platform !== 'win32') {
const cursorPaths = [
'/usr/bin/cursor',
'/usr/local/bin/cursor',
path.join(os.homedir(), '.local/bin/cursor'),
'/opt/cursor/cursor',
];
const cursorPaths = [
'/usr/bin/cursor',
'/usr/local/bin/cursor',
path.join(os.homedir(), '.local/bin/cursor'),
'/opt/cursor/cursor',
];
for (const cursorPath of cursorPaths) {
if (fs.existsSync(cursorPath)) {
// Verify cursor agent subcommand works
try {
execSync(`"${cursorPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
});
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
// Return cursor path but we'll use 'cursor agent' subcommand
return {
cliPath: cursorPath,
useWsl: false,
strategy: 'native',
};
} catch {
// cursor agent subcommand doesn't work, try next path
}
for (const cursorPath of cursorPaths) {
if (fs.existsSync(cursorPath)) {
// Verify cursor agent subcommand works
try {
execSync(`"${cursorPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
});
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
// Return cursor path but we'll use 'cursor agent' subcommand
return {
cliPath: cursorPath,
useWsl: false,
strategy: 'native',
};
} catch {
// cursor agent subcommand doesn't work, try next path
}
}
}

View File

@@ -10,14 +10,23 @@ import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { getAppSpecPath } from '@automaker/platform';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
import { FeatureLoader } from '../../../services/feature-loader.js';
import * as secureFs from '../../../lib/secure-fs.js';
import {
buildUserPrompt,
isValidEnhancementMode,
type EnhancementMode,
} from '../../../lib/enhancement-prompts.js';
import {
extractTechnologyStack,
extractXmlElements,
extractXmlSection,
unescapeXml,
} from '../../../lib/xml-extractor.js';
const logger = createLogger('EnhancePrompt');
@@ -53,6 +62,66 @@ interface EnhanceErrorResponse {
error: string;
}
async function buildProjectContext(projectPath: string): Promise<string | null> {
const contextBlocks: string[] = [];
try {
const appSpecPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
const projectName = extractXmlSection(specContent, 'project_name');
const overview = extractXmlSection(specContent, 'overview');
const techStack = extractTechnologyStack(specContent);
const coreSection = extractXmlSection(specContent, 'core_capabilities');
const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : [];
const summaryLines: string[] = [];
if (projectName) {
summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`);
}
if (overview) {
summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`);
}
if (techStack.length > 0) {
summaryLines.push(`Tech Stack: ${techStack.join(', ')}`);
}
if (coreCapabilities.length > 0) {
summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`);
}
if (summaryLines.length > 0) {
contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`);
}
} catch (error) {
logger.debug('No app_spec.txt context available for enhancement', error);
}
try {
const featureLoader = new FeatureLoader();
const features = await featureLoader.getAll(projectPath);
const featureTitles = features
.map((feature) => feature.title || feature.name || feature.id)
.filter((title) => Boolean(title));
if (featureTitles.length > 0) {
const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`);
contextBlocks.push(
`EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${
featureTitles.length > 30 ? '\n- ...' : ''
}`
);
}
} catch (error) {
logger.debug('Failed to load existing features for enhancement context', error);
}
if (contextBlocks.length === 0) {
return null;
}
return contextBlocks.join('\n\n');
}
/**
* Create the enhance request handler
*
@@ -122,6 +191,10 @@ export function createEnhanceHandler(
// Build the user prompt with few-shot examples
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
const projectContext = projectPath ? await buildProjectContext(projectPath) : null;
if (projectContext) {
logger.debug('Including project context in enhancement prompt');
}
// Check if the model is a provider model (like "GLM-4.5-Air")
// If so, get the provider config and resolved Claude model
@@ -156,7 +229,7 @@ export function createEnhanceHandler(
// The system prompt is combined with user prompt since some providers
// don't have a separate system prompt concept
const result = await simpleQuery({
prompt: `${systemPrompt}\n\n${userPrompt}`,
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
model: resolvedModel,
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
maxTurns: 1,

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

@@ -241,9 +241,9 @@ export function CreatePRDialog({
<GitPullRequest className="w-5 h-5" />
Create Pull Request
</DialogTitle>
<DialogDescription>
<DialogDescription className="break-words">
Push changes and create a pull request from{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>

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,
};
}