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:
gsxdsm
2026-02-21 08:57:04 -08:00
committed by GitHub
parent c81ea768a7
commit 3ddf26f666
41 changed files with 2705 additions and 274 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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...'}
</>
) : (
<>

View File

@@ -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(

View File

@@ -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]);

View File

@@ -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}`,
});

View File

@@ -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':

View File

@@ -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

View File

@@ -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">

View File

@@ -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">

View File

@@ -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}`,

View File

@@ -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;

View File

@@ -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(

View File

@@ -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,
]);
}

View File

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

View File

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

View File

@@ -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);

View File

@@ -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 }),

View File

@@ -18,4 +18,6 @@ export const defaultTerminalState: TerminalState = {
maxSessions: 100,
lastActiveProjectPath: null,
openTerminalMode: 'newTab',
customBackgroundColor: null,
customForegroundColor: null,
};

View File

@@ -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;

View File

@@ -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)
}

View File

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