mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 11:03:08 +00:00
Fix: Dev server detection bug fixes. Settings sync bug fixes. Cli provider fixes. Terminal background/foreground colors (#791)
* Changes from fix/dev-server-state-bug * feat: Add configurable max turns setting with user overrides. Address pr comments * fix: Update default behaviors and improve state management across server and UI * feat: Extract branch sync logic to separate service. Fix settings sync bug. Address pr comments * refactor: Extract magic numbers to named constants and improve branch tracking logic - Add DEFAULT_MAX_TURNS (1000) and MAX_ALLOWED_TURNS (2000) constants to settings-helpers - Replace hardcoded 1000 values with DEFAULT_MAX_TURNS constant throughout codebase - Improve max turns validation with explicit Number.isFinite check - Update getTrackingBranch to split on first slash instead of last for better remote parsing - Change isBranchCheckedOut return type from boolean to string|null to return worktree path - Add comments explaining skipFetch parameter in worktree creation - Fix cleanup order in AgentExecutor finally block to run before logging ``` * feat: Add comment refresh and improve model sync in PR dialog
This commit is contained in:
@@ -284,11 +284,33 @@ export function CreateWorktreeDialog({
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : '';
|
||||
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||
description: result.worktree.isNew
|
||||
? `New branch created${baseDesc}`
|
||||
: 'Using existing branch',
|
||||
});
|
||||
const commitInfo = result.worktree.baseCommitHash
|
||||
? ` (${result.worktree.baseCommitHash})`
|
||||
: '';
|
||||
|
||||
// Show sync result feedback
|
||||
const syncResult = result.worktree.syncResult;
|
||||
if (syncResult?.diverged) {
|
||||
// Branch had diverged — warn the user
|
||||
toast.warning(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||
description: `${syncResult.message}`,
|
||||
duration: 8000,
|
||||
});
|
||||
} else if (syncResult && !syncResult.synced && syncResult.message) {
|
||||
// Sync was attempted but failed (network error, etc.)
|
||||
toast.warning(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||
description: `Created with local copy. ${syncResult.message}`,
|
||||
duration: 6000,
|
||||
});
|
||||
} else {
|
||||
// Normal success — include commit info if available
|
||||
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||
description: result.worktree.isNew
|
||||
? `New branch created${baseDesc}${commitInfo}`
|
||||
: `Using existing branch${commitInfo}`,
|
||||
});
|
||||
}
|
||||
|
||||
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
|
||||
onOpenChange(false);
|
||||
setBranchName('');
|
||||
@@ -414,6 +436,12 @@ export function CreateWorktreeDialog({
|
||||
<span>Remote branch — will fetch latest before creating worktree</span>
|
||||
</div>
|
||||
)}
|
||||
{!isRemoteBaseBranch && baseBranch && !branchFetchError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
<span>Will sync with remote tracking branch if available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -454,7 +482,7 @@ export function CreateWorktreeDialog({
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{isRemoteBaseBranch ? 'Fetching & Creating...' : 'Creating...'}
|
||||
{baseBranch.trim() ? 'Syncing & Creating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -7,6 +7,11 @@ import type { DevServerInfo, WorktreeInfo } from '../types';
|
||||
|
||||
const logger = createLogger('DevServers');
|
||||
|
||||
// Timeout (ms) for port detection before showing a warning to the user
|
||||
const PORT_DETECTION_TIMEOUT_MS = 30_000;
|
||||
// Interval (ms) for periodic state reconciliation with the backend
|
||||
const STATE_RECONCILE_INTERVAL_MS = 5_000;
|
||||
|
||||
interface UseDevServersOptions {
|
||||
projectPath: string;
|
||||
}
|
||||
@@ -30,6 +35,26 @@ function buildDevServerBrowserUrl(serverUrl: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification for a detected dev server URL.
|
||||
* Extracted to avoid duplication between event handler and reconciliation paths.
|
||||
*/
|
||||
function showUrlDetectedToast(url: string, port: number): void {
|
||||
const browserUrl = buildDevServerBrowserUrl(url);
|
||||
toast.success(`Dev server running on port ${port}`, {
|
||||
description: browserUrl ? browserUrl : url,
|
||||
action: browserUrl
|
||||
? {
|
||||
label: 'Open in Browser',
|
||||
onClick: () => {
|
||||
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
||||
@@ -37,6 +62,120 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
// Track which worktrees have had their url-detected toast shown to prevent re-triggering
|
||||
const toastShownForRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Track port detection timeouts per worktree key
|
||||
const portDetectionTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
|
||||
// Track whether initial fetch has completed to avoid reconciliation race
|
||||
const initialFetchDone = useRef(false);
|
||||
|
||||
/**
|
||||
* Clear a port detection timeout for a given key
|
||||
*/
|
||||
const clearPortDetectionTimer = useCallback((key: string) => {
|
||||
const timer = portDetectionTimers.current.get(key);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
portDetectionTimers.current.delete(key);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Start a port detection timeout for a server that hasn't detected its URL yet.
|
||||
* After PORT_DETECTION_TIMEOUT_MS, if still undetected, show a warning toast
|
||||
* and attempt to reconcile state with the backend.
|
||||
*/
|
||||
const startPortDetectionTimer = useCallback(
|
||||
(key: string) => {
|
||||
// Clear any existing timer for this key
|
||||
clearPortDetectionTimer(key);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
portDetectionTimers.current.delete(key);
|
||||
|
||||
// Check if the server is still in undetected state.
|
||||
// Use a setState-updater-as-reader to access the latest state snapshot,
|
||||
// but keep the updater pure (no side effects, just reads).
|
||||
let needsReconciliation = false;
|
||||
setRunningDevServers((prev) => {
|
||||
const server = prev.get(key);
|
||||
needsReconciliation = !!server && !server.urlDetected;
|
||||
return prev; // no state change
|
||||
});
|
||||
|
||||
if (!needsReconciliation) return;
|
||||
|
||||
logger.warn(`Port detection timeout for ${key} after ${PORT_DETECTION_TIMEOUT_MS}ms`);
|
||||
|
||||
// Try to reconcile with backend - the server may have detected the URL
|
||||
// but the WebSocket event was missed
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listDevServers) return;
|
||||
const result = await api.worktree.listDevServers();
|
||||
if (result.success && result.result?.servers) {
|
||||
const backendServer = result.result.servers.find(
|
||||
(s) => normalizePath(s.worktreePath) === key
|
||||
);
|
||||
if (backendServer && backendServer.urlDetected) {
|
||||
// Backend has detected the URL - update our state
|
||||
logger.info(`Port detection reconciled from backend for ${key}`);
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, {
|
||||
...backendServer,
|
||||
urlDetected: true,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
if (!toastShownForRef.current.has(key)) {
|
||||
toastShownForRef.current.add(key);
|
||||
showUrlDetectedToast(backendServer.url, backendServer.port);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!backendServer) {
|
||||
// Server is no longer running on the backend - remove from state
|
||||
logger.info(`Server ${key} no longer running on backend, removing from state`);
|
||||
setRunningDevServers((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
toastShownForRef.current.delete(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to reconcile port detection:', error);
|
||||
}
|
||||
|
||||
// If we get here, the backend also hasn't detected the URL - show warning
|
||||
toast.warning('Port detection is taking longer than expected', {
|
||||
description:
|
||||
'The dev server may be slow to start, or the port output format is not recognized.',
|
||||
action: {
|
||||
label: 'Retry',
|
||||
onClick: () => {
|
||||
// Use ref to get the latest startPortDetectionTimer, avoiding stale closure
|
||||
startPortDetectionTimerRef.current(key);
|
||||
},
|
||||
},
|
||||
duration: 10000,
|
||||
});
|
||||
}, PORT_DETECTION_TIMEOUT_MS);
|
||||
|
||||
portDetectionTimers.current.set(key, timer);
|
||||
},
|
||||
[clearPortDetectionTimer]
|
||||
);
|
||||
|
||||
// Ref to hold the latest startPortDetectionTimer callback, avoiding stale closures
|
||||
// in long-lived callbacks like toast action handlers
|
||||
const startPortDetectionTimerRef = useRef(startPortDetectionTimer);
|
||||
startPortDetectionTimerRef.current = startPortDetectionTimer;
|
||||
|
||||
const fetchDevServers = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
@@ -56,19 +195,132 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
// so we don't re-trigger on initial load
|
||||
if (server.urlDetected !== false) {
|
||||
toastShownForRef.current.add(key);
|
||||
// Clear any pending detection timer since URL is already detected
|
||||
clearPortDetectionTimer(key);
|
||||
} else {
|
||||
// Server running but URL not yet detected - start timeout
|
||||
startPortDetectionTimer(key);
|
||||
}
|
||||
}
|
||||
setRunningDevServers(serversMap);
|
||||
}
|
||||
initialFetchDone.current = true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch dev servers:', error);
|
||||
initialFetchDone.current = true;
|
||||
}
|
||||
}, []);
|
||||
}, [clearPortDetectionTimer, startPortDetectionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevServers();
|
||||
}, [fetchDevServers]);
|
||||
|
||||
// Periodic state reconciliation: poll backend to catch missed WebSocket events
|
||||
// This handles edge cases like PWA restart, WebSocket reconnection gaps, etc.
|
||||
useEffect(() => {
|
||||
const reconcile = async () => {
|
||||
if (!initialFetchDone.current) return;
|
||||
// Skip reconciliation when the tab/panel is not visible to avoid
|
||||
// unnecessary API calls while the user isn't looking at the panel.
|
||||
if (document.hidden) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listDevServers) return;
|
||||
|
||||
const result = await api.worktree.listDevServers();
|
||||
if (!result.success || !result.result?.servers) return;
|
||||
|
||||
const backendServers = new Map<string, (typeof result.result.servers)[number]>();
|
||||
for (const server of result.result.servers) {
|
||||
backendServers.set(normalizePath(server.worktreePath), server);
|
||||
}
|
||||
|
||||
// Collect side-effect actions in a local array so the setState updater
|
||||
// remains pure. Side effects are executed after the state update.
|
||||
const sideEffects: Array<() => void> = [];
|
||||
|
||||
setRunningDevServers((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
|
||||
// Add or update servers from backend
|
||||
for (const [key, server] of backendServers) {
|
||||
const existing = next.get(key);
|
||||
if (!existing) {
|
||||
// Server running on backend but not in our state - add it
|
||||
sideEffects.push(() => logger.info(`Reconciliation: adding missing server ${key}`));
|
||||
next.set(key, {
|
||||
...server,
|
||||
urlDetected: server.urlDetected ?? true,
|
||||
});
|
||||
if (server.urlDetected !== false) {
|
||||
sideEffects.push(() => {
|
||||
toastShownForRef.current.add(key);
|
||||
clearPortDetectionTimer(key);
|
||||
});
|
||||
} else {
|
||||
sideEffects.push(() => startPortDetectionTimer(key));
|
||||
}
|
||||
changed = true;
|
||||
} else if (!existing.urlDetected && server.urlDetected) {
|
||||
// URL was detected on backend but we missed the event - update
|
||||
sideEffects.push(() => {
|
||||
logger.info(`Reconciliation: URL detected for ${key}`);
|
||||
clearPortDetectionTimer(key);
|
||||
if (!toastShownForRef.current.has(key)) {
|
||||
toastShownForRef.current.add(key);
|
||||
showUrlDetectedToast(server.url, server.port);
|
||||
}
|
||||
});
|
||||
next.set(key, {
|
||||
...server,
|
||||
urlDetected: true,
|
||||
});
|
||||
changed = true;
|
||||
} else if (
|
||||
existing.urlDetected &&
|
||||
server.urlDetected &&
|
||||
(existing.port !== server.port || existing.url !== server.url)
|
||||
) {
|
||||
// Port or URL changed between sessions - update
|
||||
sideEffects.push(() => logger.info(`Reconciliation: port/URL changed for ${key}`));
|
||||
next.set(key, {
|
||||
...server,
|
||||
urlDetected: true,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove servers from our state that are no longer on the backend
|
||||
for (const [key] of next) {
|
||||
if (!backendServers.has(key)) {
|
||||
sideEffects.push(() => {
|
||||
logger.info(`Reconciliation: removing stale server ${key}`);
|
||||
toastShownForRef.current.delete(key);
|
||||
clearPortDetectionTimer(key);
|
||||
});
|
||||
next.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
// Execute side effects outside the updater
|
||||
for (const fn of sideEffects) fn();
|
||||
} catch (error) {
|
||||
// Reconciliation failures are non-critical - just log and continue
|
||||
logger.debug('State reconciliation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(reconcile, STATE_RECONCILE_INTERVAL_MS);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [clearPortDetectionTimer, startPortDetectionTimer]);
|
||||
|
||||
// Subscribe to all dev server lifecycle events for reactive state updates
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
@@ -78,10 +330,24 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
if (event.type === 'dev-server:url-detected') {
|
||||
const { worktreePath, url, port } = event.payload;
|
||||
const key = normalizePath(worktreePath);
|
||||
// Clear the port detection timeout since URL was successfully detected
|
||||
clearPortDetectionTimer(key);
|
||||
let didUpdate = false;
|
||||
setRunningDevServers((prev) => {
|
||||
const existing = prev.get(key);
|
||||
if (!existing) return prev;
|
||||
// If the server isn't in our state yet (e.g., race condition on first load
|
||||
// where url-detected arrives before fetchDevServers completes), create the entry
|
||||
if (!existing) {
|
||||
const next = new Map(prev);
|
||||
next.set(key, {
|
||||
worktreePath,
|
||||
url,
|
||||
port,
|
||||
urlDetected: true,
|
||||
});
|
||||
didUpdate = true;
|
||||
return next;
|
||||
}
|
||||
// Avoid updating if already detected with same url/port
|
||||
if (existing.urlDetected && existing.url === url && existing.port === port) return prev;
|
||||
const next = new Map(prev);
|
||||
@@ -99,25 +365,15 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
// Only show toast on the transition from undetected → detected (not on re-renders/polls)
|
||||
if (!toastShownForRef.current.has(key)) {
|
||||
toastShownForRef.current.add(key);
|
||||
const browserUrl = buildDevServerBrowserUrl(url);
|
||||
toast.success(`Dev server running on port ${port}`, {
|
||||
description: browserUrl ? browserUrl : url,
|
||||
action: browserUrl
|
||||
? {
|
||||
label: 'Open in Browser',
|
||||
onClick: () => {
|
||||
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
duration: 8000,
|
||||
});
|
||||
showUrlDetectedToast(url, port);
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'dev-server:stopped') {
|
||||
// Reactively remove the server from state when it stops
|
||||
const { worktreePath } = event.payload;
|
||||
const key = normalizePath(worktreePath);
|
||||
// Clear any pending port detection timeout
|
||||
clearPortDetectionTimer(key);
|
||||
setRunningDevServers((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Map(prev);
|
||||
@@ -143,10 +399,22 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
});
|
||||
return next;
|
||||
});
|
||||
// Start port detection timeout for the new server
|
||||
startPortDetectionTimer(key);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [clearPortDetectionTimer, startPortDetectionTimer]);
|
||||
|
||||
// Cleanup all port detection timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of portDetectionTimers.current.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
portDetectionTimers.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getWorktreeKey = useCallback(
|
||||
@@ -186,6 +454,8 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
});
|
||||
return next;
|
||||
});
|
||||
// Start port detection timeout
|
||||
startPortDetectionTimer(key);
|
||||
toast.success('Dev server started, detecting port...');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to start dev server');
|
||||
@@ -197,7 +467,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
setIsStartingDevServer(false);
|
||||
}
|
||||
},
|
||||
[isStartingDevServer, projectPath]
|
||||
[isStartingDevServer, projectPath, startPortDetectionTimer]
|
||||
);
|
||||
|
||||
const handleStopDevServer = useCallback(
|
||||
@@ -214,6 +484,8 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
|
||||
if (result.success) {
|
||||
const key = normalizePath(targetPath);
|
||||
// Clear port detection timeout
|
||||
clearPortDetectionTimer(key);
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
@@ -230,7 +502,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
toast.error('Failed to stop dev server');
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
[projectPath, clearPortDetectionTimer]
|
||||
);
|
||||
|
||||
const handleOpenDevServerUrl = useCallback(
|
||||
|
||||
@@ -28,10 +28,21 @@ export function useWorktrees({
|
||||
const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
|
||||
const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
|
||||
|
||||
// Sync worktrees to Zustand store when they change
|
||||
// Sync worktrees to Zustand store when they change.
|
||||
// Use a ref to track the previous worktrees and skip the store update when the
|
||||
// data hasn't structurally changed. Without this check, every React Query refetch
|
||||
// (triggered by WebSocket event invalidations) would update the store even when
|
||||
// the worktree list is identical, causing a cascade of re-renders in BoardView →
|
||||
// selectedWorktree → useAutoMode → refreshStatus that can trigger React error #185.
|
||||
const prevWorktreesJsonRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0) {
|
||||
setWorktreesInStore(projectPath, worktrees);
|
||||
// Compare serialized worktrees to skip no-op store updates
|
||||
const json = JSON.stringify(worktrees);
|
||||
if (json !== prevWorktreesJsonRef.current) {
|
||||
prevWorktreesJsonRef.current = json;
|
||||
setWorktreesInStore(projectPath, worktrees);
|
||||
}
|
||||
}
|
||||
}, [worktrees, projectPath, setWorktreesInStore]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user