Feature: worktree view customization and stability fixes (#805)

* Changes from feature/worktree-view-customization

* Feature: Git sync, set-tracking, and push divergence handling (#796)

* Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.

* Changes from feature/worktree-view-customization

* refactor: Remove unused worktree swap and highlight props

* refactor: Consolidate feature completion logic and improve thinking level defaults

* feat: Increase max turn limit to 10000

- Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts
- Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts
- Update UI clamping logic from 2000 to 10000 in app-store.ts
- Update fallback values from 1000 to 10000 in use-settings-sync.ts
- Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS
- Update documentation to reflect new range: 1-10000

Allows agents to perform up to 10000 turns for complex feature execution.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat: Add model resolution, improve session handling, and enhance UI stability

* refactor: Remove unused sync and tracking branch props from worktree components

* feat: Add PR number update functionality to worktrees. Address pr feedback

* feat: Optimize Gemini CLI startup and add tool result tracking

* refactor: Improve error handling and simplify worktree task cleanup

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-02-23 20:31:25 -08:00
committed by GitHub
parent e7504b247f
commit 0330c70261
72 changed files with 3667 additions and 1173 deletions

View File

@@ -28,7 +28,11 @@ import { cn } from '@/lib/utils';
import { modelSupportsThinking } from '@/lib/utils';
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
import {
supportsReasoningEffort,
isAdaptiveThinkingModel,
getThinkingLevelsForModel,
} from '@automaker/types';
import {
PrioritySelector,
WorkModeSelector,
@@ -211,6 +215,7 @@ export function AddFeatureDialog({
defaultRequirePlanApproval,
useWorktrees,
defaultFeatureModel,
defaultThinkingLevel,
currentProject,
} = useAppStore();
@@ -240,7 +245,22 @@ export function AddFeatureDialog({
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry(effectiveDefaultFeatureModel);
// Apply defaultThinkingLevel from settings to the model entry.
// This ensures the "Quick-Select Defaults" thinking level setting is respected
// even when the user doesn't change the model in the dropdown.
const modelId =
typeof effectiveDefaultFeatureModel.model === 'string'
? effectiveDefaultFeatureModel.model
: '';
const availableLevels = getThinkingLevelsForModel(modelId);
const effectiveThinkingLevel = availableLevels.includes(defaultThinkingLevel)
? defaultThinkingLevel
: availableLevels[0];
setModelEntry({
...effectiveDefaultFeatureModel,
thinkingLevel: effectiveThinkingLevel,
});
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
@@ -269,6 +289,7 @@ export function AddFeatureDialog({
defaultPlanningMode,
defaultRequirePlanApproval,
effectiveDefaultFeatureModel,
defaultThinkingLevel,
useWorktrees,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
@@ -394,7 +415,19 @@ export function AddFeatureDialog({
// When a non-main worktree is selected, use its branch name for custom mode
setBranchName(selectedNonMainWorktreeBranch || '');
setPriority(2);
setModelEntry(effectiveDefaultFeatureModel);
// Apply defaultThinkingLevel to the model entry (same logic as dialog open)
const resetModelId =
typeof effectiveDefaultFeatureModel.model === 'string'
? effectiveDefaultFeatureModel.model
: '';
const resetAvailableLevels = getThinkingLevelsForModel(resetModelId);
const resetThinkingLevel = resetAvailableLevels.includes(defaultThinkingLevel)
? defaultThinkingLevel
: resetAvailableLevels[0];
setModelEntry({
...effectiveDefaultFeatureModel,
thinkingLevel: resetThinkingLevel,
});
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);

View File

@@ -0,0 +1,197 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
pr?: {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
};
}
interface ChangePRNumberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
projectPath: string | null;
onChanged: () => void;
}
export function ChangePRNumberDialog({
open,
onOpenChange,
worktree,
projectPath,
onChanged,
}: ChangePRNumberDialogProps) {
const [prNumberInput, setPrNumberInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize with current PR number when dialog opens
useEffect(() => {
if (open && worktree?.pr?.number) {
setPrNumberInput(String(worktree.pr.number));
} else if (open) {
setPrNumberInput('');
}
setError(null);
}, [open, worktree]);
const handleSubmit = useCallback(async () => {
if (!worktree) return;
const trimmed = prNumberInput.trim();
if (!/^\d+$/.test(trimmed)) {
setError('Please enter a valid positive PR number');
return;
}
const prNumber = Number(trimmed);
if (prNumber <= 0) {
setError('Please enter a valid positive PR number');
return;
}
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.updatePRNumber) {
setError('Worktree API not available');
return;
}
const result = await api.worktree.updatePRNumber(
worktree.path,
prNumber,
projectPath || undefined
);
if (result.success) {
const prInfo = result.result?.prInfo;
toast.success('PR tracking updated', {
description: prInfo?.title
? `Now tracking PR #${prNumber}: ${prInfo.title}`
: `Now tracking PR #${prNumber}`,
});
onOpenChange(false);
onChanged();
} else {
setError(result.error || 'Failed to update PR number');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update PR number');
} finally {
setIsLoading(false);
}
}, [worktree, prNumberInput, projectPath, onOpenChange, onChanged]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading) {
e.preventDefault();
handleSubmit();
}
},
[isLoading, handleSubmit]
);
if (!worktree) return null;
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isLoading) {
onOpenChange(isOpen);
}
}}
>
<DialogContent className="sm:max-w-[400px]" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />
Change Tracked PR Number
</DialogTitle>
<DialogDescription>
Update which pull request number is tracked for{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
{worktree.pr && (
<span className="block mt-1 text-xs">
Currently tracking PR #{worktree.pr.number}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="py-2 space-y-3">
<div className="space-y-2">
<Label htmlFor="pr-number">Pull Request Number</Label>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">#</span>
<Input
id="pr-number"
type="text"
inputMode="numeric"
placeholder="e.g. 42"
value={prNumberInput}
onChange={(e) => {
setPrNumberInput(e.target.value);
setError(null);
}}
disabled={isLoading}
autoFocus
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
Enter the GitHub PR number to associate with this worktree. The PR info will be
fetched from GitHub if available.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isLoading || !prNumberInput.trim()}>
{isLoading ? (
<>
<Spinner size="xs" className="mr-2" />
Updating...
</>
) : (
<>
<GitPullRequest className="w-4 h-4 mr-2" />
Update PR
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -27,6 +27,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { resolveModelString } from '@automaker/model-resolver';
interface RemoteInfo {
name: string;
@@ -313,7 +314,7 @@ export function CreatePRDialog({
const result = await api.worktree.generatePRDescription(
worktree.path,
branchNameForApi,
prDescriptionModelOverride.effectiveModel,
resolveModelString(prDescriptionModelOverride.effectiveModel),
prDescriptionModelOverride.effectiveModelEntry.thinkingLevel,
prDescriptionModelOverride.effectiveModelEntry.providerId
);
@@ -501,7 +502,7 @@ export function CreatePRDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px] flex flex-col">
<DialogContent className="sm:max-w-[550px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />

View File

@@ -25,6 +25,7 @@ export { ViewStashesDialog } from './view-stashes-dialog';
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';
export { GitPullDialog } from './git-pull-dialog';
export { ChangePRNumberDialog } from './change-pr-number-dialog';
export {
BranchConflictDialog,
type BranchConflictData,

View File

@@ -1,5 +1,6 @@
// @ts-nocheck - feature update logic with partial updates and image/file handling
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Feature,
FeatureImage,
@@ -18,11 +19,29 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardActions');
const MAX_DUPLICATES = 50;
/**
* Removes a running task from all worktrees for a given project.
* Used when stopping features to ensure the task is removed from all worktree contexts,
* not just the current one.
*/
function removeRunningTaskFromAllWorktrees(projectId: string, featureId: string): void {
const store = useAppStore.getState();
const prefix = `${projectId}::`;
for (const [key, worktreeState] of Object.entries(store.autoModeByWorktree)) {
if (key.startsWith(prefix) && worktreeState.runningTasks?.includes(featureId)) {
const branchPart = key.slice(prefix.length);
const branch = branchPart === '__main__' ? null : branchPart;
store.removeRunningTask(projectId, branch, featureId);
}
}
}
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
features: Feature[];
@@ -84,6 +103,8 @@ export function useBoardActions({
onWorktreeAutoSelect,
currentWorktreeBranch,
}: UseBoardActionsProps) {
const queryClient = useQueryClient();
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
// subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects
@@ -503,6 +524,10 @@ export function useBoardActions({
if (isRunning) {
try {
await autoMode.stopFeature(featureId);
// Remove from all worktrees
if (currentProject) {
removeRunningTaskFromAllWorktrees(currentProject.id, featureId);
}
toast.success('Agent stopped', {
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
});
@@ -533,7 +558,7 @@ export function useBoardActions({
removeFeature(featureId);
await persistFeatureDelete(featureId);
},
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete, currentProject]
);
const handleRunFeature = useCallback(
@@ -999,6 +1024,31 @@ export function useBoardActions({
? 'waiting_approval'
: 'backlog';
// Remove the running task from ALL worktrees for this project.
// autoMode.stopFeature only removes from its scoped worktree (branchName),
// but the feature may be tracked under a different worktree branch.
// Without this, runningAutoTasksAllWorktrees still contains the feature
// and the board column logic forces it into in_progress.
if (currentProject) {
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
}
// Optimistically update the React Query features cache so the board
// moves the card immediately. Without this, the card stays in
// "in_progress" until the next poll cycle (30s) because the async
// refetch races with the persistFeatureUpdate write.
if (currentProject) {
queryClient.setQueryData(
queryKeys.features.all(currentProject.path),
(oldFeatures: Feature[] | undefined) => {
if (!oldFeatures) return oldFeatures;
return oldFeatures.map((f) =>
f.id === feature.id ? { ...f, status: targetStatus } : f
);
}
);
}
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
// Must await to ensure file is written before user can restart
@@ -1020,7 +1070,7 @@ export function useBoardActions({
});
}
},
[autoMode, moveFeature, persistFeatureUpdate]
[autoMode, moveFeature, persistFeatureUpdate, currentProject, queryClient]
);
const handleStartNextFeatures = useCallback(async () => {
@@ -1137,6 +1187,12 @@ export function useBoardActions({
})
)
);
// Remove from all worktrees
if (currentProject) {
for (const feature of runningVerified) {
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
}
}
}
// Use bulk update API for a single server request instead of N individual calls

View File

@@ -6,13 +6,21 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
import {
EnhancementMode,
ENHANCEMENT_MODE_LABELS,
REWRITE_MODES,
ADDITIVE_MODES,
isAdditiveMode,
} from './enhancement-constants';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('EnhanceWithAI');
@@ -79,7 +87,10 @@ export function EnhanceWithAI({
if (result?.success && result.enhancedText) {
const originalText = value;
const enhancedText = result.enhancedText;
// For additive modes, prepend the original description above the AI-generated content
const enhancedText = isAdditiveMode(enhancementMode)
? `${originalText.trim()}\n\n${result.enhancedText.trim()}`
: result.enhancedText;
onChange(enhancedText);
// Track in history if callback provided (includes original for restoration)
@@ -119,13 +130,19 @@ export function EnhanceWithAI({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map(
([mode, label]) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{label}
</DropdownMenuItem>
)
)}
<DropdownMenuLabel>Rewrite</DropdownMenuLabel>
{REWRITE_MODES.map((mode) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{ENHANCEMENT_MODE_LABELS[mode]}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Append Details</DropdownMenuLabel>
{ADDITIVE_MODES.map((mode) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{ENHANCEMENT_MODE_LABELS[mode]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,5 +1,5 @@
/** Enhancement mode options for AI-powered prompt improvement */
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
import type { EnhancementMode } from '@automaker/types';
export type { EnhancementMode } from '@automaker/types';
/** Labels for enhancement modes displayed in the UI */
export const ENHANCEMENT_MODE_LABELS: Record<EnhancementMode, string> = {
@@ -18,3 +18,14 @@ export const ENHANCEMENT_MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
acceptance: 'Add specific acceptance criteria and test cases',
'ux-reviewer': 'Add user experience considerations and flows',
};
/** Modes that rewrite/replace the entire description */
export const REWRITE_MODES: EnhancementMode[] = ['improve', 'simplify'];
/** Modes that append additional content below the original description */
export const ADDITIVE_MODES: EnhancementMode[] = ['technical', 'acceptance', 'ux-reviewer'];
/** Check if a mode appends content rather than replacing */
export function isAdditiveMode(mode: EnhancementMode): boolean {
return ADDITIVE_MODES.includes(mode);
}

View File

@@ -43,6 +43,9 @@ import {
XCircle,
CheckCircle,
Settings2,
ArrowLeftRight,
Check,
Hash,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -105,6 +108,7 @@ interface WorktreeActionsDropdownProps {
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
@@ -149,6 +153,14 @@ interface WorktreeActionsDropdownProps {
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
/** Available worktrees for swapping into this slot (non-main only) */
availableWorktreesForSwap?: WorktreeInfo[];
/** The slot index for this tab in the pinned list (0-based, excluding main) */
slotIndex?: number;
/** Callback when user swaps this slot to a different worktree */
onSwapWorktree?: (slotIndex: number, newBranch: string) => void;
/** List of currently pinned branch names (to show which are pinned in the swap dropdown) */
pinnedBranches?: string[];
}
/**
@@ -259,6 +271,7 @@ export function WorktreeActionsDropdown({
onDiscardChanges,
onCommit,
onCreatePR,
onChangePRNumber,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
@@ -287,6 +300,10 @@ export function WorktreeActionsDropdown({
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
availableWorktreesForSwap,
slotIndex,
onSwapWorktree,
pinnedBranches,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -1334,6 +1351,12 @@ export function WorktreeActionsDropdown({
<Zap className="w-3.5 h-3.5 mr-2" />
Address PR Comments
</DropdownMenuItem>
{onChangePRNumber && (
<DropdownMenuItem onClick={() => onChangePRNumber(worktree)} className="text-xs">
<Hash className="w-3.5 h-3.5 mr-2" />
Change PR Number
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
@@ -1359,6 +1382,36 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Swap Worktree submenu - only shown for non-main slots when there are other worktrees to swap to */}
{!worktree.isMain &&
availableWorktreesForSwap &&
availableWorktreesForSwap.length > 1 &&
slotIndex !== undefined &&
onSwapWorktree && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<ArrowLeftRight className="w-3.5 h-3.5 mr-2" />
Swap Worktree
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-64 max-h-80 overflow-y-auto">
{availableWorktreesForSwap
.filter((wt) => wt.branch !== worktree.branch)
.map((wt) => {
const isPinned = pinnedBranches?.includes(wt.branch);
return (
<DropdownMenuItem
key={wt.path}
onSelect={() => onSwapWorktree(slotIndex, wt.branch)}
className="flex items-center gap-2 cursor-pointer font-mono text-xs"
>
<span className="truncate flex-1">{wt.branch}</span>
{isPinned && <Check className="w-3 h-3 shrink-0 text-muted-foreground" />}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!worktree.isMain && (
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}

View File

@@ -102,6 +102,7 @@ export interface WorktreeDropdownProps {
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
@@ -148,6 +149,8 @@ export interface WorktreeDropdownProps {
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
/** When false, the trigger button uses a subdued style instead of the primary highlight. Defaults to true. */
highlightTrigger?: boolean;
}
/**
@@ -215,6 +218,7 @@ export function WorktreeDropdown({
onDiscardChanges,
onCommit,
onCreatePR,
onChangePRNumber,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
@@ -245,10 +249,13 @@ export function WorktreeDropdown({
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
highlightTrigger = true,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
const displayBranch = selectedWorktree?.branch || 'Select worktree';
const displayBranch =
selectedWorktree?.branch ??
(worktrees.length > 0 ? `+${worktrees.length} more` : 'Select worktree');
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
displayBranch,
MAX_TRIGGER_BRANCH_NAME_LENGTH
@@ -292,15 +299,28 @@ export function WorktreeDropdown({
const triggerButton = useMemo(
() => (
<Button
variant="outline"
variant={selectedWorktree && highlightTrigger ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 px-3 gap-1.5 font-mono text-xs bg-secondary/50 hover:bg-secondary min-w-0 border-r-0 rounded-r-none'
'h-7 px-3 gap-1.5 font-mono text-xs min-w-0',
selectedWorktree &&
highlightTrigger &&
'bg-primary text-primary-foreground border-r-0 rounded-l-md rounded-r-none',
selectedWorktree &&
!highlightTrigger &&
'bg-secondary/50 hover:bg-secondary border-r-0 rounded-l-md rounded-r-none',
!selectedWorktree && 'bg-secondary/50 hover:bg-secondary rounded-md'
)}
disabled={isActivating}
>
{/* Running/Activating indicator */}
{(selectedStatus.isRunning || isActivating) && <Spinner size="xs" className="shrink-0" />}
{(selectedStatus.isRunning || isActivating) && (
<Spinner
size="xs"
className="shrink-0"
variant={selectedWorktree && highlightTrigger ? 'foreground' : 'primary'}
/>
)}
{/* Branch icon */}
<GitBranch className="w-3.5 h-3.5 shrink-0" />
@@ -403,7 +423,14 @@ export function WorktreeDropdown({
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
</Button>
),
[isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts]
[
isActivating,
selectedStatus,
truncatedBranch,
selectedWorktree,
branchCardCounts,
highlightTrigger,
]
);
// Wrap trigger button with dropdown trigger first to ensure ref is passed correctly
@@ -490,7 +517,7 @@ export function WorktreeDropdown({
{selectedWorktree?.isMain && (
<BranchSwitchDropdown
worktree={selectedWorktree}
isSelected={true}
isSelected={highlightTrigger}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
@@ -507,7 +534,7 @@ export function WorktreeDropdown({
{selectedWorktree && (
<WorktreeActionsDropdown
worktree={selectedWorktree}
isSelected={true}
isSelected={highlightTrigger}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
@@ -541,6 +568,7 @@ export function WorktreeDropdown({
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}

View File

@@ -66,6 +66,7 @@ interface WorktreeTabProps {
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
@@ -118,6 +119,14 @@ interface WorktreeTabProps {
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
/** Available worktrees for swapping into this slot (non-main only) */
availableWorktreesForSwap?: WorktreeInfo[];
/** The slot index for this tab in the pinned list (0-based, excluding main) */
slotIndex?: number;
/** Callback when user swaps this slot to a different worktree */
onSwapWorktree?: (slotIndex: number, newBranch: string) => void;
/** List of currently pinned branch names (to show which are pinned in the swap dropdown) */
pinnedBranches?: string[];
}
export function WorktreeTab({
@@ -164,6 +173,7 @@ export function WorktreeTab({
onDiscardChanges,
onCommit,
onCreatePR,
onChangePRNumber,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
@@ -196,6 +206,10 @@ export function WorktreeTab({
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
availableWorktreesForSwap,
slotIndex,
onSwapWorktree,
pinnedBranches,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
@@ -542,6 +556,7 @@ export function WorktreeTab({
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
@@ -570,6 +585,10 @@ export function WorktreeTab({
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
remotesWithBranch={remotesWithBranch}
availableWorktreesForSwap={availableWorktreesForSwap}
slotIndex={slotIndex}
onSwapWorktree={onSwapWorktree}
pinnedBranches={pinnedBranches}
/>
</div>
);

View File

@@ -120,6 +120,7 @@ export interface WorktreePanelProps {
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;

View File

@@ -26,11 +26,11 @@ import {
} from './hooks';
import {
WorktreeTab,
WorktreeDropdown,
DevServerLogsPanel,
WorktreeMobileDropdown,
WorktreeActionsDropdown,
BranchSwitchDropdown,
WorktreeDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import {
@@ -50,8 +50,9 @@ import type { SelectRemoteOperation } from '../dialogs';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { getElectronAPI } from '@/lib/electron';
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
const WORKTREE_DROPDOWN_THRESHOLD = 3;
// Stable empty array to avoid creating a new [] reference on every render
// when pinnedWorktreeBranchesByProject[projectPath] is undefined
const EMPTY_BRANCHES: string[] = [];
export function WorktreePanel({
projectPath,
@@ -59,6 +60,7 @@ export function WorktreePanel({
onDeleteWorktree,
onCommit,
onCreatePR,
onChangePRNumber,
onCreateBranch,
onAddressPRComments,
onAutoAddressPRComments,
@@ -99,7 +101,6 @@ export function WorktreePanel({
aheadCount,
behindCount,
hasRemoteBranch,
trackingRemote,
getTrackingRemote,
remotesWithBranch,
isLoadingBranches,
@@ -139,13 +140,107 @@ export function WorktreePanel({
features,
});
// Pinned worktrees count from store
const pinnedWorktreesCount = useAppStore(
(state) => state.pinnedWorktreesCountByProject[projectPath] ?? 0
);
const pinnedWorktreeBranchesRaw = useAppStore(
(state) => state.pinnedWorktreeBranchesByProject[projectPath]
);
const pinnedWorktreeBranches = pinnedWorktreeBranchesRaw ?? EMPTY_BRANCHES;
const setPinnedWorktreeBranches = useAppStore((state) => state.setPinnedWorktreeBranches);
const swapPinnedWorktreeBranch = useAppStore((state) => state.swapPinnedWorktreeBranch);
// Resolve pinned worktrees from explicit branch assignments
// Shows exactly pinnedWorktreesCount slots, each with a specific worktree.
// Main worktree is always slot 0. Other slots can be swapped by the user.
const pinnedWorktrees = useMemo(() => {
const mainWt = worktrees.find((w) => w.isMain);
const otherWts = worktrees.filter((w) => !w.isMain);
// Slot 0 is always main worktree
const result: WorktreeInfo[] = mainWt ? [mainWt] : [];
// pinnedWorktreesCount represents only non-main worktrees; main is always shown separately
const otherSlotCount = Math.max(0, pinnedWorktreesCount);
if (otherSlotCount > 0 && otherWts.length > 0) {
// Use explicit branch assignments if available
const assignedBranches = pinnedWorktreeBranches;
const usedBranches = new Set<string>();
for (let i = 0; i < otherSlotCount; i++) {
const assignedBranch = assignedBranches[i];
let wt: WorktreeInfo | undefined;
// Try to find the explicitly assigned worktree
if (assignedBranch) {
wt = otherWts.find((w) => w.branch === assignedBranch && !usedBranches.has(w.branch));
}
// Fall back to next available worktree if assigned one doesn't exist
if (!wt) {
wt = otherWts.find((w) => !usedBranches.has(w.branch));
}
if (wt) {
result.push(wt);
usedBranches.add(wt.branch);
}
}
}
return result;
}, [worktrees, pinnedWorktreesCount, pinnedWorktreeBranches]);
// All non-main worktrees available for swapping into slots
const availableWorktreesForSwap = useMemo(() => {
return worktrees.filter((w) => !w.isMain);
}, [worktrees]);
// Handle swapping a worktree in a specific slot
const handleSwapWorktreeSlot = useCallback(
(slotIndex: number, newBranch: string) => {
swapPinnedWorktreeBranch(projectPath, slotIndex, newBranch);
},
[projectPath, swapPinnedWorktreeBranch]
);
// Initialize pinned branch assignments when worktrees change
// This ensures new worktrees get default slot assignments
// Read store state directly inside the effect to avoid a dependency cycle
// (the effect writes to the same state it would otherwise depend on)
useEffect(() => {
const mainWt = worktrees.find((w) => w.isMain);
const otherWts = worktrees.filter((w) => !w.isMain);
const otherSlotCount = Math.max(0, pinnedWorktreesCount);
const storedBranches = useAppStore.getState().pinnedWorktreeBranchesByProject[projectPath];
if (otherSlotCount > 0 && otherWts.length > 0) {
const existing = storedBranches ?? [];
if (existing.length < otherSlotCount) {
const used = new Set(existing.filter(Boolean));
const filled = [...existing];
for (const wt of otherWts) {
if (filled.length >= otherSlotCount) break;
if (!used.has(wt.branch)) {
filled.push(wt.branch);
used.add(wt.branch);
}
}
if (filled.length > 0) {
setPinnedWorktreeBranches(projectPath, filled);
}
}
}
}, [worktrees, pinnedWorktreesCount, projectPath, setPinnedWorktreeBranches]);
// Auto-mode state management using the store
// Use separate selectors to avoid creating new object references on each render
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const currentProject = useAppStore((state) => state.currentProject);
const setAutoModeRunning = useAppStore((state) => state.setAutoModeRunning);
const getMaxConcurrencyForWorktree = useAppStore((state) => state.getMaxConcurrencyForWorktree);
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
const getAutoModeWorktreeKey = useCallback(
(projectId: string, branchName: string | null): string => {
@@ -651,18 +746,6 @@ export function WorktreePanel({
// Keep logPanelWorktree set for smooth close animation
}, []);
// Wrap handleStartDevServer to auto-open the logs panel so the user
// can see output immediately (including failure reasons)
const handleStartDevServerAndShowLogs = useCallback(
async (worktree: WorktreeInfo) => {
// Open logs panel immediately so output is visible from the start
setLogPanelWorktree(worktree);
setLogPanelOpen(true);
await handleStartDevServer(worktree);
},
[handleStartDevServer]
);
// Handle opening the push to remote dialog
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
setPushToRemoteWorktree(worktree);
@@ -887,7 +970,6 @@ export function WorktreePanel({
);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Mobile view: single dropdown for all worktrees
if (isMobile) {
@@ -965,12 +1047,13 @@ export function WorktreePanel({
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
@@ -1145,56 +1228,124 @@ export function WorktreePanel({
);
}
// Use dropdown layout when worktree count meets or exceeds the threshold
const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD;
// Desktop view: pinned worktrees as individual tabs (each slot can be swapped)
// Desktop view: full tabs layout or dropdown layout depending on worktree count
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
</span>
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground mr-2 shrink-0">Worktree:</span>
{/* Dropdown layout for 3+ worktrees */}
{useDropdownLayout ? (
<>
<WorktreeDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
{/* When only 1 pinned slot (main only) and there are other worktrees,
use a compact dropdown to switch between them without highlighting main */}
{pinnedWorktreesCount === 0 && availableWorktreesForSwap.length > 0 ? (
<WorktreeDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
isDevServerRunning={isDevServerRunning}
getDevServerInfo={getDevServerInfo}
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
isTestRunningForWorktree={isTestRunningForWorktree}
getTestSessionInfo={getTestSessionInfo}
onSelectWorktree={handleSelectWorktree}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
getTrackingRemote={getTrackingRemote}
gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotesCache={remotesCache}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
highlightTrigger={false}
/>
) : pinnedWorktreesCount === 0 ? (
/* Only main worktree, no others exist - render main tab without highlight */
mainWorktree && (
<WorktreeTab
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={false}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
isDevServerRunning={isDevServerRunning}
getDevServerInfo={getDevServerInfo}
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
isTestRunningForWorktree={isTestRunningForWorktree}
getTestSessionInfo={getTestSessionInfo}
onSelectWorktree={handleSelectWorktree}
// Branch switching props
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
// Action dropdown props
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={trackingRemote}
getTrackingRemote={getTrackingRemote}
trackingRemote={getTrackingRemote(mainWorktree.path)}
gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
@@ -1205,7 +1356,7 @@ export function WorktreePanel({
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotesWithBranch={remotesWithBranch}
remotesCache={remotesCache}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1214,12 +1365,13 @@ export function WorktreePanel({
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
@@ -1233,247 +1385,138 @@ export function WorktreePanel({
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/>
{useWorktreesEnabled && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}
</>
)
) : (
/* Standard tabs layout for 1-2 worktrees */
/* Multiple pinned slots - show individual tabs */
pinnedWorktrees.map((worktree, index) => {
const hasOtherWorktrees = worktrees.length > 1;
const effectiveIsSelected =
isWorktreeSelected(worktree) && (hasOtherWorktrees || !worktree.isMain);
// Slot index for swap (0-based, excluding main which is always slot 0)
const slotIndex = worktree.isMain ? -1 : index - (pinnedWorktrees[0]?.isMain ? 1 : 0);
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={branchCardCounts?.[worktree.branch]}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={effectiveIsSelected}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(worktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
availableWorktreesForSwap={!worktree.isMain ? availableWorktreesForSwap : undefined}
slotIndex={slotIndex >= 0 ? slotIndex : undefined}
onSwapWorktree={slotIndex >= 0 ? handleSwapWorktreeSlot : undefined}
pinnedBranches={pinnedWorktrees.map((w) => w.branch)}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
/>
);
})
)}
{/* Create and refresh buttons */}
{useWorktreesEnabled && (
<>
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(mainWorktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
{/* Worktrees section - only show if enabled and not using dropdown layout */}
{useWorktreesEnabled && (
<>
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(worktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}