mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10: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:
@@ -21,6 +21,7 @@ import {
|
||||
Maximize2,
|
||||
Check,
|
||||
Undo2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -574,16 +575,6 @@ export function PRCommentResolutionDialog({
|
||||
// Track previous open state to detect when dialog opens
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
// Sync model defaults only when dialog opens (transitions from closed to open)
|
||||
useEffect(() => {
|
||||
const justOpened = open && !wasOpenRef.current;
|
||||
wasOpenRef.current = open;
|
||||
|
||||
if (justOpened) {
|
||||
setModelEntry(effectiveDefaultFeatureModel);
|
||||
}
|
||||
}, [open, effectiveDefaultFeatureModel]);
|
||||
|
||||
const handleModelChange = useCallback((entry: PhaseModelEntry) => {
|
||||
// Normalize thinking level when switching between adaptive and non-adaptive models
|
||||
const isNewModelAdaptive =
|
||||
@@ -605,10 +596,23 @@ export function PRCommentResolutionDialog({
|
||||
const {
|
||||
data,
|
||||
isLoading: loading,
|
||||
isFetching: refreshing,
|
||||
error,
|
||||
refetch,
|
||||
} = useGitHubPRReviewComments(currentProject?.path, open ? pr.number : undefined);
|
||||
|
||||
// Sync model defaults and refresh comments when dialog opens (transitions from closed to open)
|
||||
useEffect(() => {
|
||||
const justOpened = open && !wasOpenRef.current;
|
||||
wasOpenRef.current = open;
|
||||
|
||||
if (justOpened) {
|
||||
setModelEntry(effectiveDefaultFeatureModel);
|
||||
// Force refresh PR comments from GitHub when dialog opens
|
||||
refetch();
|
||||
}
|
||||
}, [open, effectiveDefaultFeatureModel, refetch]);
|
||||
|
||||
const allComments = useMemo(() => {
|
||||
const raw = data?.comments ?? [];
|
||||
// Sort based on current sort order
|
||||
@@ -846,10 +850,22 @@ export function PRCommentResolutionDialog({
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-blue-500" />
|
||||
Manage PR Review Comments
|
||||
</DialogTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-blue-500" />
|
||||
Manage PR Review Comments
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => refetch()}
|
||||
disabled={refreshing}
|
||||
title="Refresh comments"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Select comments from PR #{pr.number} to create feature tasks that address them.
|
||||
</DialogDescription>
|
||||
|
||||
@@ -459,13 +459,21 @@ export function BoardView() {
|
||||
prevWorktreePathRef.current = currentWorktreePath;
|
||||
}, [currentWorktreePath, currentProject?.path, queryClient]);
|
||||
|
||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||
const worktrees = useMemo(
|
||||
() =>
|
||||
currentProject
|
||||
? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
|
||||
: EMPTY_WORKTREES,
|
||||
[currentProject, worktreesByProject]
|
||||
// Select worktrees for the current project directly from the store.
|
||||
// Using a project-scoped selector prevents re-renders when OTHER projects'
|
||||
// worktrees change (the old selector subscribed to the entire worktreesByProject
|
||||
// object, causing unnecessary re-renders that cascaded into selectedWorktree →
|
||||
// useAutoMode → refreshStatus → setAutoModeRunning → store update → re-render loop
|
||||
// that could trigger React error #185 on initial project open).
|
||||
const currentProjectPath = currentProject?.path;
|
||||
const worktrees = useAppStore(
|
||||
useCallback(
|
||||
(s) =>
|
||||
currentProjectPath
|
||||
? (s.worktreesByProject[currentProjectPath] ?? EMPTY_WORKTREES)
|
||||
: EMPTY_WORKTREES,
|
||||
[currentProjectPath]
|
||||
)
|
||||
);
|
||||
|
||||
// Get the branch for the currently selected worktree
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -92,9 +92,9 @@ export function GitHubPRsView() {
|
||||
|
||||
// Start the feature immediately after creation
|
||||
const api = getElectronAPI();
|
||||
if (api.features?.run) {
|
||||
if (api.autoMode?.runFeature) {
|
||||
try {
|
||||
await api.features.run(currentProject.path, featureId);
|
||||
await api.autoMode.runFeature(currentProject.path, featureId);
|
||||
toast.success('Feature created and started', {
|
||||
description: `Addressing review comments on PR #${pr.number}`,
|
||||
});
|
||||
|
||||
@@ -63,6 +63,8 @@ export function SettingsView() {
|
||||
setPromptCustomization,
|
||||
skipSandboxWarning,
|
||||
setSkipSandboxWarning,
|
||||
defaultMaxTurns,
|
||||
setDefaultMaxTurns,
|
||||
} = useAppStore();
|
||||
|
||||
// Global theme (project-specific themes are managed in Project Settings)
|
||||
@@ -173,6 +175,7 @@ export function SettingsView() {
|
||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||
enableAiCommitMessages={enableAiCommitMessages}
|
||||
defaultFeatureModel={defaultFeatureModel}
|
||||
defaultMaxTurns={defaultMaxTurns}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
||||
@@ -180,6 +183,7 @@ export function SettingsView() {
|
||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||
onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
|
||||
onDefaultFeatureModelChange={setDefaultFeatureModel}
|
||||
onDefaultMaxTurnsChange={setDefaultMaxTurns}
|
||||
/>
|
||||
);
|
||||
case 'worktrees':
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
FlaskConical,
|
||||
TestTube,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
FastForward,
|
||||
Sparkles,
|
||||
Cpu,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -34,6 +37,7 @@ interface FeatureDefaultsSectionProps {
|
||||
defaultRequirePlanApproval: boolean;
|
||||
enableAiCommitMessages: boolean;
|
||||
defaultFeatureModel: PhaseModelEntry;
|
||||
defaultMaxTurns: number;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
||||
@@ -41,6 +45,7 @@ interface FeatureDefaultsSectionProps {
|
||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||
onEnableAiCommitMessagesChange: (value: boolean) => void;
|
||||
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
|
||||
onDefaultMaxTurnsChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function FeatureDefaultsSection({
|
||||
@@ -51,6 +56,7 @@ export function FeatureDefaultsSection({
|
||||
defaultRequirePlanApproval,
|
||||
enableAiCommitMessages,
|
||||
defaultFeatureModel,
|
||||
defaultMaxTurns,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
onSkipVerificationInAutoModeChange,
|
||||
@@ -58,7 +64,16 @@ export function FeatureDefaultsSection({
|
||||
onDefaultRequirePlanApprovalChange,
|
||||
onEnableAiCommitMessagesChange,
|
||||
onDefaultFeatureModelChange,
|
||||
onDefaultMaxTurnsChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
const [maxTurnsInput, setMaxTurnsInput] = useState(String(defaultMaxTurns));
|
||||
|
||||
// Keep the displayed input in sync if the prop changes after mount
|
||||
// (e.g. when settings are loaded asynchronously or reset from parent)
|
||||
useEffect(() => {
|
||||
setMaxTurnsInput(String(defaultMaxTurns));
|
||||
}, [defaultMaxTurns]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -104,6 +119,55 @@ export function FeatureDefaultsSection({
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Max Turns Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-orange-500/10">
|
||||
<RotateCcw className="w-5 h-5 text-orange-500" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="default-max-turns" className="text-foreground font-medium">
|
||||
Max Agent Turns
|
||||
</Label>
|
||||
<Input
|
||||
id="default-max-turns"
|
||||
type="number"
|
||||
min={1}
|
||||
max={2000}
|
||||
step={1}
|
||||
value={maxTurnsInput}
|
||||
onChange={(e) => {
|
||||
setMaxTurnsInput(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const value = Number(maxTurnsInput);
|
||||
if (Number.isInteger(value) && value >= 1 && value <= 2000) {
|
||||
onDefaultMaxTurnsChange(value);
|
||||
} else {
|
||||
// Reset to current valid value if invalid (including decimals like "1.5")
|
||||
setMaxTurnsInput(String(defaultMaxTurns));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
className="w-[100px] h-8 text-right"
|
||||
data-testid="default-max-turns-input"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Maximum number of tool-call round-trips the AI agent can perform per feature. Higher
|
||||
values allow more complex tasks but use more API credits. Default: 1000, Range:
|
||||
1-2000. Supported by Claude and Codex providers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Planning Mode Default */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<div
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
Terminal,
|
||||
SquarePlus,
|
||||
SplitSquareHorizontal,
|
||||
Palette,
|
||||
Type,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -38,6 +41,8 @@ export function TerminalSection() {
|
||||
defaultTerminalId,
|
||||
setDefaultTerminalId,
|
||||
setOpenTerminalMode,
|
||||
setTerminalBackgroundColor,
|
||||
setTerminalForegroundColor,
|
||||
} = useAppStore();
|
||||
|
||||
const {
|
||||
@@ -48,6 +53,8 @@ export function TerminalSection() {
|
||||
lineHeight,
|
||||
defaultFontSize,
|
||||
openTerminalMode,
|
||||
customBackgroundColor,
|
||||
customForegroundColor,
|
||||
} = terminalState;
|
||||
|
||||
// Get available external terminals
|
||||
@@ -205,6 +212,138 @@ export function TerminalSection() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Background Color */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Background Color</Label>
|
||||
{customBackgroundColor && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setTerminalBackgroundColor(null);
|
||||
toast.success('Background color reset to theme default');
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Override the terminal background color. Leave empty to use the theme default.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg border border-border/50 shadow-sm flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: customBackgroundColor || 'var(--card)',
|
||||
}}
|
||||
>
|
||||
<Palette
|
||||
className={cn(
|
||||
'w-5 h-5',
|
||||
customBackgroundColor ? 'text-white/80' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="color"
|
||||
value={customBackgroundColor || '#000000'}
|
||||
onChange={(e) => {
|
||||
const color = e.target.value;
|
||||
setTerminalBackgroundColor(color);
|
||||
}}
|
||||
className="w-14 h-10 p-1 cursor-pointer bg-transparent border-border/50"
|
||||
title="Pick a color"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customBackgroundColor || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
// Validate hex color format
|
||||
if (value === '' || /^#[0-9A-Fa-f]{0,6}$/.test(value)) {
|
||||
if (value === '' || /^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
setTerminalBackgroundColor(value || null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="e.g., #1a1a1a"
|
||||
className="flex-1 bg-accent/30 border-border/50 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Foreground Color */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Foreground Color</Label>
|
||||
{customForegroundColor && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setTerminalForegroundColor(null);
|
||||
toast.success('Foreground color reset to theme default');
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Override the terminal text/foreground color. Leave empty to use the theme default.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg border border-border/50 shadow-sm flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: customForegroundColor || 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
<Type
|
||||
className={cn(
|
||||
'w-5 h-5',
|
||||
customForegroundColor ? 'text-black/80' : 'text-background'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="color"
|
||||
value={customForegroundColor || '#ffffff'}
|
||||
onChange={(e) => {
|
||||
const color = e.target.value;
|
||||
setTerminalForegroundColor(color);
|
||||
}}
|
||||
className="w-14 h-10 p-1 cursor-pointer bg-transparent border-border/50"
|
||||
title="Pick a color"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customForegroundColor || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
// Validate hex color format
|
||||
if (value === '' || /^#[0-9A-Fa-f]{0,6}$/.test(value)) {
|
||||
if (value === '' || /^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
setTerminalForegroundColor(value || null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="e.g., #ffffff"
|
||||
className="flex-1 bg-accent/30 border-border/50 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Font Size */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
GitBranch,
|
||||
ChevronDown,
|
||||
FolderGit,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
@@ -276,6 +279,8 @@ export function TerminalView({
|
||||
setTerminalLineHeight,
|
||||
setTerminalScrollbackLines,
|
||||
setTerminalScreenReaderMode,
|
||||
setTerminalBackgroundColor,
|
||||
setTerminalForegroundColor,
|
||||
updateTerminalPanelSizes,
|
||||
currentWorktreeByProject,
|
||||
worktreesByProject,
|
||||
@@ -1997,6 +2002,119 @@ export function TerminalView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Background Color */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">Background Color</Label>
|
||||
{terminalState.customBackgroundColor && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => setTerminalBackgroundColor(null)}
|
||||
title="Reset to theme default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-7 h-7 rounded border border-border/50 shrink-0 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: terminalState.customBackgroundColor || 'var(--card)',
|
||||
}}
|
||||
>
|
||||
<Palette
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
terminalState.customBackgroundColor
|
||||
? 'text-white/80'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="color"
|
||||
value={terminalState.customBackgroundColor || '#000000'}
|
||||
onChange={(e) => setTerminalBackgroundColor(e.target.value)}
|
||||
className="w-10 h-7 p-0.5 cursor-pointer bg-transparent border-border/50 shrink-0"
|
||||
title="Pick a background color"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={terminalState.customBackgroundColor || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === '' || /^#[0-9A-Fa-f]{0,6}$/.test(value)) {
|
||||
if (value === '' || /^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
setTerminalBackgroundColor(value || null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="#1a1a1a"
|
||||
className="flex-1 h-7 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Foreground Color */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">Foreground Color</Label>
|
||||
{terminalState.customForegroundColor && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => setTerminalForegroundColor(null)}
|
||||
title="Reset to theme default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-7 h-7 rounded border border-border/50 shrink-0 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor:
|
||||
terminalState.customForegroundColor || 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
<Type
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
terminalState.customForegroundColor
|
||||
? 'text-black/80'
|
||||
: 'text-background'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="color"
|
||||
value={terminalState.customForegroundColor || '#ffffff'}
|
||||
onChange={(e) => setTerminalForegroundColor(e.target.value)}
|
||||
className="w-10 h-7 p-0.5 cursor-pointer bg-transparent border-border/50 shrink-0"
|
||||
title="Pick a foreground color"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={terminalState.customForegroundColor || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === '' || /^#[0-9A-Fa-f]{0,6}$/.test(value)) {
|
||||
if (value === '' || /^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
setTerminalForegroundColor(value || null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="#ffffff"
|
||||
className="flex-1 h-7 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screen Reader */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@@ -202,16 +202,25 @@ export function TerminalPanel({
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
|
||||
// Get terminal settings from store - grouped with shallow comparison to reduce re-renders
|
||||
const { defaultRunScript, screenReaderMode, fontFamily, scrollbackLines, lineHeight } =
|
||||
useAppStore(
|
||||
useShallow((state) => ({
|
||||
defaultRunScript: state.terminalState.defaultRunScript,
|
||||
screenReaderMode: state.terminalState.screenReaderMode,
|
||||
fontFamily: state.terminalState.fontFamily,
|
||||
scrollbackLines: state.terminalState.scrollbackLines,
|
||||
lineHeight: state.terminalState.lineHeight,
|
||||
}))
|
||||
);
|
||||
const {
|
||||
defaultRunScript,
|
||||
screenReaderMode,
|
||||
fontFamily,
|
||||
scrollbackLines,
|
||||
lineHeight,
|
||||
customBackgroundColor,
|
||||
customForegroundColor,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
defaultRunScript: state.terminalState.defaultRunScript,
|
||||
screenReaderMode: state.terminalState.screenReaderMode,
|
||||
fontFamily: state.terminalState.fontFamily,
|
||||
scrollbackLines: state.terminalState.scrollbackLines,
|
||||
lineHeight: state.terminalState.lineHeight,
|
||||
customBackgroundColor: state.terminalState.customBackgroundColor,
|
||||
customForegroundColor: state.terminalState.customForegroundColor,
|
||||
}))
|
||||
);
|
||||
|
||||
// Action setters are stable references, can use individual selectors
|
||||
const setTerminalDefaultRunScript = useAppStore((state) => state.setTerminalDefaultRunScript);
|
||||
@@ -679,7 +688,7 @@ export function TerminalPanel({
|
||||
if (!mounted || !terminalRef.current) return;
|
||||
|
||||
// Get terminal theme matching the app theme
|
||||
const terminalTheme = getTerminalTheme(themeRef.current);
|
||||
const baseTheme = getTerminalTheme(themeRef.current);
|
||||
|
||||
// Get settings from store (read at initialization time)
|
||||
const terminalSettings = useAppStore.getState().terminalState;
|
||||
@@ -687,6 +696,18 @@ export function TerminalPanel({
|
||||
const terminalFontFamily = getTerminalFontFamily(terminalSettings.fontFamily);
|
||||
const terminalScrollback = terminalSettings.scrollbackLines || 5000;
|
||||
const terminalLineHeight = terminalSettings.lineHeight || 1.0;
|
||||
const customBgColor = terminalSettings.customBackgroundColor;
|
||||
const customFgColor = terminalSettings.customForegroundColor;
|
||||
|
||||
// Apply custom colors if set
|
||||
const terminalTheme =
|
||||
customBgColor || customFgColor
|
||||
? {
|
||||
...baseTheme,
|
||||
...(customBgColor && { background: customBgColor }),
|
||||
...(customFgColor && { foreground: customFgColor }),
|
||||
}
|
||||
: baseTheme;
|
||||
|
||||
// Create terminal instance with the current global font size and theme
|
||||
const terminal = new Terminal({
|
||||
@@ -1484,15 +1505,23 @@ export function TerminalPanel({
|
||||
}
|
||||
}, [fontSize, isTerminalReady]);
|
||||
|
||||
// Update terminal theme when app theme changes (including system preference)
|
||||
// Update terminal theme when app theme or custom colors change (including system preference)
|
||||
useEffect(() => {
|
||||
if (xtermRef.current && isTerminalReady) {
|
||||
// Clear any search decorations first to prevent stale color artifacts
|
||||
searchAddonRef.current?.clearDecorations();
|
||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
||||
const baseTheme = getTerminalTheme(resolvedTheme);
|
||||
const terminalTheme =
|
||||
customBackgroundColor || customForegroundColor
|
||||
? {
|
||||
...baseTheme,
|
||||
...(customBackgroundColor && { background: customBackgroundColor }),
|
||||
...(customForegroundColor && { foreground: customForegroundColor }),
|
||||
}
|
||||
: baseTheme;
|
||||
xtermRef.current.options.theme = terminalTheme;
|
||||
}
|
||||
}, [resolvedTheme, isTerminalReady]);
|
||||
}, [resolvedTheme, customBackgroundColor, customForegroundColor, isTerminalReady]);
|
||||
|
||||
// Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0)
|
||||
useEffect(() => {
|
||||
@@ -1925,6 +1954,10 @@ export function TerminalPanel({
|
||||
// Get current terminal theme for xterm styling (resolved for system preference)
|
||||
const currentTerminalTheme = getTerminalTheme(resolvedTheme);
|
||||
|
||||
// Apply custom background/foreground colors if set, otherwise use theme defaults
|
||||
const terminalBackgroundColor = customBackgroundColor ?? currentTerminalTheme.background;
|
||||
const terminalForegroundColor = customForegroundColor ?? currentTerminalTheme.foreground;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
@@ -2395,7 +2428,7 @@ export function TerminalPanel({
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: currentTerminalTheme.background }}
|
||||
style={{ backgroundColor: terminalBackgroundColor }}
|
||||
onContextMenu={handleContextMenu}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
@@ -2456,8 +2489,8 @@ export function TerminalPanel({
|
||||
className="flex-1 overflow-auto"
|
||||
style={
|
||||
{
|
||||
backgroundColor: currentTerminalTheme.background,
|
||||
color: currentTerminalTheme.foreground,
|
||||
backgroundColor: terminalBackgroundColor,
|
||||
color: terminalForegroundColor,
|
||||
fontFamily: getTerminalFontFamily(fontFamily),
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight: `${lineHeight || 1.0}`,
|
||||
|
||||
@@ -81,12 +81,12 @@ export function getTerminalFontFamily(fontValue: string | undefined): string {
|
||||
return fontValue;
|
||||
}
|
||||
|
||||
// Dark theme (default)
|
||||
// Dark theme (default) - true black background with white foreground
|
||||
const darkTheme: TerminalTheme = {
|
||||
background: '#0a0a0a',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
cursorAccent: '#0a0a0a',
|
||||
background: '#000000',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#1e1e1e',
|
||||
red: '#f44747',
|
||||
@@ -626,4 +626,29 @@ export function getTerminalTheme(theme: ThemeMode): TerminalTheme {
|
||||
return terminalThemes[theme] || darkTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get terminal theme with optional custom color overrides
|
||||
* @param theme - The app theme mode
|
||||
* @param customBackgroundColor - Optional custom background color (hex string) to override theme default
|
||||
* @param customForegroundColor - Optional custom foreground/text color (hex string) to override theme default
|
||||
* @returns Terminal theme with custom colors if provided
|
||||
*/
|
||||
export function getTerminalThemeWithOverride(
|
||||
theme: ThemeMode,
|
||||
customBackgroundColor: string | null,
|
||||
customForegroundColor?: string | null
|
||||
): TerminalTheme {
|
||||
const baseTheme = getTerminalTheme(theme);
|
||||
|
||||
if (customBackgroundColor || customForegroundColor) {
|
||||
return {
|
||||
...baseTheme,
|
||||
...(customBackgroundColor && { background: customBackgroundColor }),
|
||||
...(customForegroundColor && { foreground: customForegroundColor }),
|
||||
};
|
||||
}
|
||||
|
||||
return baseTheme;
|
||||
}
|
||||
|
||||
export default terminalThemes;
|
||||
|
||||
@@ -108,10 +108,17 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Derive branchName from worktree:
|
||||
// If worktree is provided, use its branch name (even for main worktree, as it might be on a feature branch)
|
||||
// If not provided, default to null (main worktree default)
|
||||
// IMPORTANT: Depend on primitive values (isMain, branch) instead of the worktree object
|
||||
// reference to avoid re-computing when the parent passes a new object with the same values.
|
||||
// This prevents a cascading re-render loop: new worktree ref → new branchName useMemo →
|
||||
// new refreshStatus callback → effect re-fires → store update → re-render → React error #185.
|
||||
const worktreeIsMain = worktree?.isMain;
|
||||
const worktreeBranch = worktree?.branch;
|
||||
const hasWorktree = worktree !== undefined;
|
||||
const branchName = useMemo(() => {
|
||||
if (!worktree) return null;
|
||||
return worktree.isMain ? null : worktree.branch || null;
|
||||
}, [worktree]);
|
||||
if (!hasWorktree) return null;
|
||||
return worktreeIsMain ? null : worktreeBranch || null;
|
||||
}, [hasWorktree, worktreeIsMain, worktreeBranch]);
|
||||
|
||||
// Helper to look up project ID from path
|
||||
const getProjectIdFromPath = useCallback(
|
||||
|
||||
@@ -26,7 +26,6 @@ export function useProjectSettingsLoader() {
|
||||
(state) => state.setAutoDismissInitScriptIndicator
|
||||
);
|
||||
const setWorktreeCopyFiles = useAppStore((state) => state.setWorktreeCopyFiles);
|
||||
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
|
||||
|
||||
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
|
||||
|
||||
@@ -116,30 +115,39 @@ export function useProjectSettingsLoader() {
|
||||
|
||||
// Check if we need to update the project
|
||||
const storeState = useAppStore.getState();
|
||||
const updatedProject = storeState.currentProject;
|
||||
if (updatedProject && updatedProject.path === projectPath) {
|
||||
// snapshotProject is the store's current value at this point in time;
|
||||
// it is distinct from updatedProjectData which is the new value we build below.
|
||||
const snapshotProject = storeState.currentProject;
|
||||
if (snapshotProject && snapshotProject.path === projectPath) {
|
||||
const needsUpdate =
|
||||
(activeClaudeApiProfileId !== undefined &&
|
||||
updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) ||
|
||||
snapshotProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) ||
|
||||
(phaseModelOverrides !== undefined &&
|
||||
JSON.stringify(updatedProject.phaseModelOverrides) !==
|
||||
JSON.stringify(snapshotProject.phaseModelOverrides) !==
|
||||
JSON.stringify(phaseModelOverrides));
|
||||
|
||||
if (needsUpdate) {
|
||||
const updatedProjectData = {
|
||||
...updatedProject,
|
||||
...snapshotProject,
|
||||
...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }),
|
||||
...(phaseModelOverrides !== undefined && { phaseModelOverrides }),
|
||||
};
|
||||
|
||||
// Update currentProject
|
||||
setCurrentProject(updatedProjectData);
|
||||
|
||||
// Also update the project in the projects array to keep them in sync
|
||||
// Update both currentProject and projects array in a single setState call
|
||||
// to avoid two separate re-renders that can cascade during initialization
|
||||
// and contribute to React error #185 (maximum update depth exceeded).
|
||||
const updatedProjects = storeState.projects.map((p) =>
|
||||
p.id === updatedProject.id ? updatedProjectData : p
|
||||
p.id === snapshotProject.id ? updatedProjectData : p
|
||||
);
|
||||
useAppStore.setState({ projects: updatedProjects });
|
||||
// NOTE: Intentionally bypasses setCurrentProject() to avoid a second
|
||||
// render cycle that can trigger React error #185 (maximum update depth
|
||||
// exceeded). This means persistEffectiveThemeForProject() is skipped,
|
||||
// which is safe because only activeClaudeApiProfileId and
|
||||
// phaseModelOverrides are mutated here — not the project theme.
|
||||
useAppStore.setState({
|
||||
currentProject: updatedProjectData,
|
||||
projects: updatedProjects,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -159,6 +167,5 @@ export function useProjectSettingsLoader() {
|
||||
setDefaultDeleteBranch,
|
||||
setAutoDismissInitScriptIndicator,
|
||||
setWorktreeCopyFiles,
|
||||
setCurrentProject,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -213,6 +213,12 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
// Claude Compatible Providers (new system)
|
||||
claudeCompatibleProviders:
|
||||
(state.claudeCompatibleProviders as GlobalSettings['claudeCompatibleProviders']) ?? [],
|
||||
// Settings that were previously missing from migration (added for sync parity)
|
||||
enableAiCommitMessages: state.enableAiCommitMessages as boolean | undefined,
|
||||
enableSkills: state.enableSkills as boolean | undefined,
|
||||
skillsSources: state.skillsSources as GlobalSettings['skillsSources'] | undefined,
|
||||
enableSubagents: state.enableSubagents as boolean | undefined,
|
||||
subagentsSources: state.subagentsSources as GlobalSettings['subagentsSources'] | undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse localStorage settings:', error);
|
||||
@@ -357,6 +363,27 @@ export function mergeSettings(
|
||||
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
|
||||
}
|
||||
|
||||
// Preserve new settings fields from localStorage if server has defaults
|
||||
// Use nullish coalescing to accept stored falsy values (e.g. false)
|
||||
if (localSettings.enableAiCommitMessages != null && merged.enableAiCommitMessages == null) {
|
||||
merged.enableAiCommitMessages = localSettings.enableAiCommitMessages;
|
||||
}
|
||||
if (localSettings.enableSkills != null && merged.enableSkills == null) {
|
||||
merged.enableSkills = localSettings.enableSkills;
|
||||
}
|
||||
if (localSettings.skillsSources && (!merged.skillsSources || merged.skillsSources.length === 0)) {
|
||||
merged.skillsSources = localSettings.skillsSources;
|
||||
}
|
||||
if (localSettings.enableSubagents != null && merged.enableSubagents == null) {
|
||||
merged.enableSubagents = localSettings.enableSubagents;
|
||||
}
|
||||
if (
|
||||
localSettings.subagentsSources &&
|
||||
(!merged.subagentsSources || merged.subagentsSources.length === 0)
|
||||
) {
|
||||
merged.subagentsSources = localSettings.subagentsSources;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -728,7 +755,12 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
disabledProviders: settings.disabledProviders ?? [],
|
||||
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
|
||||
enableAiCommitMessages: settings.enableAiCommitMessages ?? true,
|
||||
enableSkills: settings.enableSkills ?? true,
|
||||
skillsSources: settings.skillsSources ?? ['user', 'project'],
|
||||
enableSubagents: settings.enableSubagents ?? true,
|
||||
subagentsSources: settings.subagentsSources ?? ['user', 'project'],
|
||||
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? true,
|
||||
skipSandboxWarning: settings.skipSandboxWarning ?? false,
|
||||
codexAutoLoadAgents: settings.codexAutoLoadAgents ?? false,
|
||||
codexSandboxMode: settings.codexSandboxMode ?? 'workspace-write',
|
||||
@@ -763,11 +795,25 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
editorFontFamily: settings.editorFontFamily ?? 'default',
|
||||
editorAutoSave: settings.editorAutoSave ?? false,
|
||||
editorAutoSaveDelay: settings.editorAutoSaveDelay ?? 1000,
|
||||
// Terminal font (nested in terminalState)
|
||||
...(settings.terminalFontFamily && {
|
||||
// Terminal settings (nested in terminalState)
|
||||
...((settings.terminalFontFamily ||
|
||||
(settings as unknown as Record<string, unknown>).terminalCustomBackgroundColor !==
|
||||
undefined ||
|
||||
(settings as unknown as Record<string, unknown>).terminalCustomForegroundColor !==
|
||||
undefined) && {
|
||||
terminalState: {
|
||||
...current.terminalState,
|
||||
fontFamily: settings.terminalFontFamily,
|
||||
...(settings.terminalFontFamily && { fontFamily: settings.terminalFontFamily }),
|
||||
...((settings as unknown as Record<string, unknown>).terminalCustomBackgroundColor !==
|
||||
undefined && {
|
||||
customBackgroundColor: (settings as unknown as Record<string, unknown>)
|
||||
.terminalCustomBackgroundColor as string | null,
|
||||
}),
|
||||
...((settings as unknown as Record<string, unknown>).terminalCustomForegroundColor !==
|
||||
undefined && {
|
||||
customForegroundColor: (settings as unknown as Record<string, unknown>)
|
||||
.terminalCustomForegroundColor as string | null,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -827,6 +873,11 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
defaultReasoningEffort: state.defaultReasoningEffort,
|
||||
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
||||
disabledProviders: state.disabledProviders,
|
||||
enableAiCommitMessages: state.enableAiCommitMessages,
|
||||
enableSkills: state.enableSkills,
|
||||
skillsSources: state.skillsSources,
|
||||
enableSubagents: state.enableSubagents,
|
||||
subagentsSources: state.subagentsSources,
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
skipSandboxWarning: state.skipSandboxWarning,
|
||||
codexAutoLoadAgents: state.codexAutoLoadAgents,
|
||||
@@ -858,6 +909,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
editorAutoSave: state.editorAutoSave,
|
||||
editorAutoSaveDelay: state.editorAutoSaveDelay,
|
||||
terminalFontFamily: state.terminalState.fontFamily,
|
||||
terminalCustomBackgroundColor: state.terminalState.customBackgroundColor,
|
||||
terminalCustomForegroundColor: state.terminalState.customForegroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'fontFamilyMono',
|
||||
'terminalFontFamily', // Maps to terminalState.fontFamily
|
||||
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
||||
'terminalCustomBackgroundColor', // Maps to terminalState.customBackgroundColor
|
||||
'terminalCustomForegroundColor', // Maps to terminalState.customForegroundColor
|
||||
'sidebarOpen',
|
||||
'sidebarStyle',
|
||||
'collapsedNavSections',
|
||||
@@ -90,8 +92,14 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'editorAutoSave',
|
||||
'editorAutoSaveDelay',
|
||||
'defaultTerminalId',
|
||||
'enableAiCommitMessages',
|
||||
'enableSkills',
|
||||
'skillsSources',
|
||||
'enableSubagents',
|
||||
'subagentsSources',
|
||||
'promptCustomization',
|
||||
'eventHooks',
|
||||
'claudeCompatibleProviders',
|
||||
'claudeApiProfiles',
|
||||
'activeClaudeApiProfileId',
|
||||
'projects',
|
||||
@@ -109,6 +117,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'codexEnableImages',
|
||||
'codexAdditionalDirs',
|
||||
'codexThreadId',
|
||||
// Max Turns Setting
|
||||
'defaultMaxTurns',
|
||||
// UI State (previously in localStorage)
|
||||
'worktreePanelCollapsed',
|
||||
'lastProjectDir',
|
||||
@@ -143,6 +153,12 @@ function getSettingsFieldValue(
|
||||
if (field === 'openTerminalMode') {
|
||||
return appState.terminalState.openTerminalMode;
|
||||
}
|
||||
if (field === 'terminalCustomBackgroundColor') {
|
||||
return appState.terminalState.customBackgroundColor;
|
||||
}
|
||||
if (field === 'terminalCustomForegroundColor') {
|
||||
return appState.terminalState.customForegroundColor;
|
||||
}
|
||||
if (field === 'autoModeByWorktree') {
|
||||
// Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
|
||||
const autoModeByWorktree = appState.autoModeByWorktree;
|
||||
@@ -186,6 +202,16 @@ function hasSettingsFieldChanged(
|
||||
if (field === 'openTerminalMode') {
|
||||
return newState.terminalState.openTerminalMode !== prevState.terminalState.openTerminalMode;
|
||||
}
|
||||
if (field === 'terminalCustomBackgroundColor') {
|
||||
return (
|
||||
newState.terminalState.customBackgroundColor !== prevState.terminalState.customBackgroundColor
|
||||
);
|
||||
}
|
||||
if (field === 'terminalCustomForegroundColor') {
|
||||
return (
|
||||
newState.terminalState.customForegroundColor !== prevState.terminalState.customForegroundColor
|
||||
);
|
||||
}
|
||||
const key = field as keyof typeof newState;
|
||||
return newState[key] !== prevState[key];
|
||||
}
|
||||
@@ -731,6 +757,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
||||
: { model: 'claude-opus' },
|
||||
muteDoneSound: serverSettings.muteDoneSound,
|
||||
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 1000,
|
||||
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||
@@ -747,7 +774,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
copilotDefaultModel: sanitizedCopilotDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
disabledProviders: serverSettings.disabledProviders ?? [],
|
||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? true,
|
||||
keyboardShortcuts: {
|
||||
...currentAppState.keyboardShortcuts,
|
||||
...(serverSettings.keyboardShortcuts as unknown as Partial<
|
||||
@@ -786,7 +813,12 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
codexAdditionalDirs: serverSettings.codexAdditionalDirs ?? [],
|
||||
codexThreadId: serverSettings.codexThreadId,
|
||||
// Terminal settings (nested in terminalState)
|
||||
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
|
||||
...((serverSettings.terminalFontFamily ||
|
||||
serverSettings.openTerminalMode ||
|
||||
(serverSettings as unknown as Record<string, unknown>).terminalCustomBackgroundColor !==
|
||||
undefined ||
|
||||
(serverSettings as unknown as Record<string, unknown>).terminalCustomForegroundColor !==
|
||||
undefined) && {
|
||||
terminalState: {
|
||||
...currentAppState.terminalState,
|
||||
...(serverSettings.terminalFontFamily && {
|
||||
@@ -795,6 +827,16 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
...(serverSettings.openTerminalMode && {
|
||||
openTerminalMode: serverSettings.openTerminalMode,
|
||||
}),
|
||||
...((serverSettings as unknown as Record<string, unknown>)
|
||||
.terminalCustomBackgroundColor !== undefined && {
|
||||
customBackgroundColor: (serverSettings as unknown as Record<string, unknown>)
|
||||
.terminalCustomBackgroundColor as string | null,
|
||||
}),
|
||||
...((serverSettings as unknown as Record<string, unknown>)
|
||||
.terminalCustomForegroundColor !== undefined && {
|
||||
customForegroundColor: (serverSettings as unknown as Record<string, unknown>)
|
||||
.terminalCustomForegroundColor as string | null,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -182,25 +182,39 @@ function selectAutoOpenProject(
|
||||
|
||||
function RootLayoutContent() {
|
||||
const location = useLocation();
|
||||
const {
|
||||
setIpcConnected,
|
||||
projects,
|
||||
currentProject,
|
||||
projectHistory,
|
||||
upsertAndSetCurrentProject,
|
||||
getEffectiveTheme,
|
||||
getEffectiveFontSans,
|
||||
getEffectiveFontMono,
|
||||
// Subscribe to theme and font state to trigger re-renders when they change
|
||||
theme,
|
||||
fontFamilySans,
|
||||
fontFamilyMono,
|
||||
sidebarStyle,
|
||||
skipSandboxWarning,
|
||||
setSkipSandboxWarning,
|
||||
fetchCodexModels,
|
||||
} = useAppStore();
|
||||
const { setupComplete, codexCliStatus } = useSetupStore();
|
||||
|
||||
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
|
||||
// re-rendering on every store mutation. The bare call subscribes to the ENTIRE store,
|
||||
// which during initialization causes cascading re-renders as multiple effects write
|
||||
// to the store (settings hydration, project settings, auto-open, etc.). With enough
|
||||
// rapid mutations, React hits the maximum update depth limit (error #185).
|
||||
//
|
||||
// Each selector only triggers a re-render when its specific slice of state changes.
|
||||
const projects = useAppStore((s) => s.projects);
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const projectHistory = useAppStore((s) => s.projectHistory);
|
||||
const sidebarStyle = useAppStore((s) => s.sidebarStyle);
|
||||
const skipSandboxWarning = useAppStore((s) => s.skipSandboxWarning);
|
||||
// Subscribe to theme and font state to trigger re-renders when they change
|
||||
const theme = useAppStore((s) => s.theme);
|
||||
const fontFamilySans = useAppStore((s) => s.fontFamilySans);
|
||||
const fontFamilyMono = useAppStore((s) => s.fontFamilyMono);
|
||||
// Subscribe to previewTheme so that getEffectiveTheme() re-renders when
|
||||
// hover previews change the document theme. Without this, the selector
|
||||
// for getEffectiveTheme (a stable function ref) won't trigger re-renders.
|
||||
const previewTheme = useAppStore((s) => s.previewTheme);
|
||||
void previewTheme; // Used only for subscription
|
||||
// Actions (stable references from Zustand - never change between renders)
|
||||
const setIpcConnected = useAppStore((s) => s.setIpcConnected);
|
||||
const upsertAndSetCurrentProject = useAppStore((s) => s.upsertAndSetCurrentProject);
|
||||
const getEffectiveTheme = useAppStore((s) => s.getEffectiveTheme);
|
||||
const getEffectiveFontSans = useAppStore((s) => s.getEffectiveFontSans);
|
||||
const getEffectiveFontMono = useAppStore((s) => s.getEffectiveFontMono);
|
||||
const setSkipSandboxWarning = useAppStore((s) => s.setSkipSandboxWarning);
|
||||
const fetchCodexModels = useAppStore((s) => s.fetchCodexModels);
|
||||
|
||||
const setupComplete = useSetupStore((s) => s.setupComplete);
|
||||
const codexCliStatus = useSetupStore((s) => s.codexCliStatus);
|
||||
const navigate = useNavigate();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||
|
||||
@@ -369,6 +369,7 @@ const initialState: AppState = {
|
||||
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
|
||||
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'none',
|
||||
defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none',
|
||||
defaultMaxTurns: DEFAULT_GLOBAL_SETTINGS.defaultMaxTurns ?? 1000,
|
||||
pendingPlanApproval: null,
|
||||
claudeRefreshInterval: 60,
|
||||
claudeUsage: null,
|
||||
@@ -991,7 +992,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
const key = get().getWorktreeKey(projectId, branchName);
|
||||
set((state) => {
|
||||
const current = state.autoModeByWorktree[key] || {
|
||||
isRunning: true,
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName,
|
||||
};
|
||||
@@ -1109,7 +1110,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { skipVerificationInAutoMode: enabled });
|
||||
await httpApi.settings.updateGlobal({ skipVerificationInAutoMode: enabled });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync skipVerificationInAutoMode:', error);
|
||||
}
|
||||
@@ -1119,7 +1120,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { enableAiCommitMessages: enabled });
|
||||
await httpApi.settings.updateGlobal({ enableAiCommitMessages: enabled });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync enableAiCommitMessages:', error);
|
||||
}
|
||||
@@ -1129,7 +1130,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { mergePostAction: action });
|
||||
await httpApi.settings.updateGlobal({ mergePostAction: action });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync mergePostAction:', error);
|
||||
}
|
||||
@@ -1139,7 +1140,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { planUseSelectedWorktreeBranch: enabled });
|
||||
await httpApi.settings.updateGlobal({ planUseSelectedWorktreeBranch: enabled });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync planUseSelectedWorktreeBranch:', error);
|
||||
}
|
||||
@@ -1149,7 +1150,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { addFeatureUseSelectedWorktreeBranch: enabled });
|
||||
await httpApi.settings.updateGlobal({ addFeatureUseSelectedWorktreeBranch: enabled });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync addFeatureUseSelectedWorktreeBranch:', error);
|
||||
}
|
||||
@@ -1222,7 +1223,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { phaseModels: get().phaseModels });
|
||||
await httpApi.settings.updateGlobal({ phaseModels: get().phaseModels });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync phase model:', error);
|
||||
}
|
||||
@@ -1234,7 +1235,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { phaseModels: get().phaseModels });
|
||||
await httpApi.settings.updateGlobal({ phaseModels: get().phaseModels });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync phase models:', error);
|
||||
}
|
||||
@@ -1244,7 +1245,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { phaseModels: DEFAULT_PHASE_MODELS });
|
||||
await httpApi.settings.updateGlobal({ phaseModels: DEFAULT_PHASE_MODELS });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync phase models reset:', error);
|
||||
}
|
||||
@@ -1279,7 +1280,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ codexAutoLoadAgents: enabled });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { codexAutoLoadAgents: enabled });
|
||||
await httpApi.settings.updateGlobal({ codexAutoLoadAgents: enabled });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync codexAutoLoadAgents:', error);
|
||||
}
|
||||
@@ -1288,7 +1289,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ codexSandboxMode: mode });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { codexSandboxMode: mode });
|
||||
await httpApi.settings.updateGlobal({ codexSandboxMode: mode });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync codexSandboxMode:', error);
|
||||
}
|
||||
@@ -1297,7 +1298,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ codexApprovalPolicy: policy });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { codexApprovalPolicy: policy });
|
||||
await httpApi.settings.updateGlobal({ codexApprovalPolicy: policy });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync codexApprovalPolicy:', error);
|
||||
}
|
||||
@@ -1306,7 +1307,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ codexEnableWebSearch: enabled });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { codexEnableWebSearch: enabled });
|
||||
await httpApi.settings.updateGlobal({ codexEnableWebSearch: enabled });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync codexEnableWebSearch:', error);
|
||||
}
|
||||
@@ -1315,7 +1316,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ codexEnableImages: enabled });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { codexEnableImages: enabled });
|
||||
await httpApi.settings.updateGlobal({ codexEnableImages: enabled });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync codexEnableImages:', error);
|
||||
}
|
||||
@@ -1375,7 +1376,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ autoLoadClaudeMd: enabled });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { autoLoadClaudeMd: enabled });
|
||||
await httpApi.settings.updateGlobal({ autoLoadClaudeMd: enabled });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync autoLoadClaudeMd:', error);
|
||||
}
|
||||
@@ -1384,7 +1385,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ skipSandboxWarning: skip });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { skipSandboxWarning: skip });
|
||||
await httpApi.settings.updateGlobal({ skipSandboxWarning: skip });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync skipSandboxWarning:', error);
|
||||
}
|
||||
@@ -1407,7 +1408,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ promptCustomization: customization });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { promptCustomization: customization });
|
||||
await httpApi.settings.updateGlobal({ promptCustomization: customization });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync prompt customization:', error);
|
||||
}
|
||||
@@ -1423,7 +1424,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', {
|
||||
await httpApi.settings.updateGlobal({
|
||||
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1438,7 +1439,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', {
|
||||
await httpApi.settings.updateGlobal({
|
||||
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1451,7 +1452,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', {
|
||||
await httpApi.settings.updateGlobal({
|
||||
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1462,7 +1463,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ claudeCompatibleProviders: providers });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { claudeCompatibleProviders: providers });
|
||||
await httpApi.settings.updateGlobal({ claudeCompatibleProviders: providers });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync Claude-compatible providers:', error);
|
||||
}
|
||||
@@ -1475,7 +1476,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', {
|
||||
await httpApi.settings.updateGlobal({
|
||||
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1490,7 +1491,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { claudeApiProfiles: get().claudeApiProfiles });
|
||||
await httpApi.settings.updateGlobal({ claudeApiProfiles: get().claudeApiProfiles });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync Claude API profiles:', error);
|
||||
}
|
||||
@@ -1503,7 +1504,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { claudeApiProfiles: get().claudeApiProfiles });
|
||||
await httpApi.settings.updateGlobal({ claudeApiProfiles: get().claudeApiProfiles });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync Claude API profiles:', error);
|
||||
}
|
||||
@@ -1516,7 +1517,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', {
|
||||
await httpApi.settings.updateGlobal({
|
||||
claudeApiProfiles: get().claudeApiProfiles,
|
||||
activeClaudeApiProfileId: get().activeClaudeApiProfileId,
|
||||
});
|
||||
@@ -1528,7 +1529,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ activeClaudeApiProfileId: id });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { activeClaudeApiProfileId: id });
|
||||
await httpApi.settings.updateGlobal({ activeClaudeApiProfileId: id });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync active Claude API profile:', error);
|
||||
}
|
||||
@@ -1537,7 +1538,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ claudeApiProfiles: profiles });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { claudeApiProfiles: profiles });
|
||||
await httpApi.settings.updateGlobal({ claudeApiProfiles: profiles });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync Claude API profiles:', error);
|
||||
}
|
||||
@@ -1947,6 +1948,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
terminalState: { ...state.terminalState, openTerminalMode: mode },
|
||||
})),
|
||||
|
||||
setTerminalBackgroundColor: (color) =>
|
||||
set((state) => ({
|
||||
terminalState: { ...state.terminalState, customBackgroundColor: color },
|
||||
})),
|
||||
|
||||
setTerminalForegroundColor: (color) =>
|
||||
set((state) => ({
|
||||
terminalState: { ...state.terminalState, customForegroundColor: color },
|
||||
})),
|
||||
|
||||
addTerminalTab: (name) => {
|
||||
const newTabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const tabNumber = get().terminalState.tabs.length + 1;
|
||||
@@ -2341,7 +2352,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { defaultThinkingLevel: level });
|
||||
await httpApi.settings.updateGlobal({ defaultThinkingLevel: level });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync defaultThinkingLevel:', error);
|
||||
}
|
||||
@@ -2352,12 +2363,27 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { defaultReasoningEffort: effort });
|
||||
await httpApi.settings.updateGlobal({ defaultReasoningEffort: effort });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync defaultReasoningEffort:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultMaxTurns: async (maxTurns: number) => {
|
||||
// Guard against NaN/Infinity before flooring and clamping
|
||||
const safeValue = Number.isFinite(maxTurns) ? maxTurns : 1;
|
||||
// Clamp to valid range
|
||||
const clamped = Math.max(1, Math.min(2000, Math.floor(safeValue)));
|
||||
set({ defaultMaxTurns: clamped });
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ defaultMaxTurns: clamped });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync defaultMaxTurns:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Plan Approval actions
|
||||
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
||||
|
||||
|
||||
@@ -18,4 +18,6 @@ export const defaultTerminalState: TerminalState = {
|
||||
maxSessions: 100,
|
||||
lastActiveProjectPath: null,
|
||||
openTerminalMode: 'newTab',
|
||||
customBackgroundColor: null,
|
||||
customForegroundColor: null,
|
||||
};
|
||||
|
||||
@@ -182,6 +182,9 @@ export interface AppState {
|
||||
defaultThinkingLevel: ThinkingLevel;
|
||||
defaultReasoningEffort: ReasoningEffort;
|
||||
|
||||
// Default max turns for agent execution (1-2000)
|
||||
defaultMaxTurns: number;
|
||||
|
||||
// Cursor CLI Settings (global)
|
||||
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
|
||||
cursorDefaultModel: CursorModelId; // Default Cursor model selection
|
||||
@@ -564,6 +567,7 @@ export interface AppActions {
|
||||
toggleFavoriteModel: (modelId: string) => void;
|
||||
setDefaultThinkingLevel: (level: ThinkingLevel) => void;
|
||||
setDefaultReasoningEffort: (effort: ReasoningEffort) => void;
|
||||
setDefaultMaxTurns: (maxTurns: number) => void;
|
||||
|
||||
// Cursor CLI Settings actions
|
||||
setEnabledCursorModels: (models: CursorModelId[]) => void;
|
||||
@@ -708,6 +712,8 @@ export interface AppActions {
|
||||
setTerminalMaxSessions: (maxSessions: number) => void;
|
||||
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
|
||||
setOpenTerminalMode: (mode: 'newTab' | 'split') => void;
|
||||
setTerminalBackgroundColor: (color: string | null) => void;
|
||||
setTerminalForegroundColor: (color: string | null) => void;
|
||||
addTerminalTab: (name?: string) => string;
|
||||
removeTerminalTab: (tabId: string) => void;
|
||||
setActiveTerminalTab: (tabId: string) => void;
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface TerminalState {
|
||||
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
|
||||
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
|
||||
openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action
|
||||
customBackgroundColor: string | null; // Custom background color override (hex color string, null = use theme default)
|
||||
customForegroundColor: string | null; // Custom foreground/text color override (hex color string, null = use theme default)
|
||||
}
|
||||
|
||||
// Persisted terminal layout - now includes sessionIds for reconnection
|
||||
@@ -79,4 +81,6 @@ export interface PersistedTerminalSettings {
|
||||
lineHeight: number;
|
||||
maxSessions: number;
|
||||
openTerminalMode: 'newTab' | 'split';
|
||||
customBackgroundColor: string | null; // Custom background color override (hex color string, null = use theme default)
|
||||
customForegroundColor: string | null; // Custom foreground/text color override (hex color string, null = use theme default)
|
||||
}
|
||||
|
||||
13
apps/ui/src/types/electron.d.ts
vendored
13
apps/ui/src/types/electron.d.ts
vendored
@@ -910,6 +910,19 @@ export interface WorktreeAPI {
|
||||
path: string;
|
||||
branch: string;
|
||||
isNew: boolean;
|
||||
/** Short commit hash the worktree is based on */
|
||||
baseCommitHash?: string;
|
||||
/** Result of syncing the base branch with its remote tracking branch */
|
||||
syncResult?: {
|
||||
/** Whether the sync succeeded */
|
||||
synced: boolean;
|
||||
/** The remote that was synced from */
|
||||
remote?: string;
|
||||
/** Human-readable message about the sync result */
|
||||
message?: string;
|
||||
/** Whether the branch had diverged (local commits ahead of remote) */
|
||||
diverged?: boolean;
|
||||
};
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
Reference in New Issue
Block a user