Fix concurrency limits and remote branch fetching issues (#788)

* Changes from fix/bug-fixes

* feat: Refactor worktree iteration and improve error logging across services

* feat: Extract URL/port patterns to module level and fix abort condition

* fix: Improve IPv6 loopback handling, select component layout, and terminal UI

* feat: Add thinking level defaults and adjust list row padding

* Update apps/ui/src/store/app-store.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit

* feat: Add tracked remote detection to pull dialog flow

* feat: Add merge state tracking to git operations

* feat: Improve merge detection and add post-merge action preferences

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Pass merge detection info to stash reapplication and handle merge state consistently

* fix: Call onPulled callback in merge handlers and add validation checks

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-20 13:48:22 -08:00
committed by GitHub
parent 7df2182818
commit 0a5540c9a2
70 changed files with 4525 additions and 857 deletions

View File

@@ -17,6 +17,8 @@ import {
interface CardActionsProps {
feature: Feature;
isCurrentAutoTask: boolean;
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
isRunningTask?: boolean;
hasContext?: boolean;
shortcutKey?: string;
isSelectionMode?: boolean;
@@ -36,6 +38,7 @@ interface CardActionsProps {
export const CardActions = memo(function CardActions({
feature,
isCurrentAutoTask,
isRunningTask = false,
hasContext: _hasContext,
shortcutKey,
isSelectionMode = false,
@@ -340,7 +343,57 @@ export const CardActions = memo(function CardActions({
) : null}
</>
)}
{/* Running task with stale status: feature is tracked as running but status hasn't updated yet.
Show Logs/Stop controls instead of Make to avoid confusing UI. */}
{!isCurrentAutoTask &&
isRunningTask &&
(feature.status === 'backlog' ||
feature.status === 'interrupted' ||
feature.status === 'ready') && (
<>
{onViewOutput && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
{shortcutKey && (
<span
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</span>
)}
</Button>
)}
{onForceStop && (
<Button
variant="destructive"
size="sm"
className="h-7 text-[11px] px-2 shrink-0"
onClick={(e) => {
e.stopPropagation();
onForceStop();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask &&
!isRunningTask &&
(feature.status === 'backlog' ||
feature.status === 'interrupted' ||
feature.status === 'ready') && (

View File

@@ -114,15 +114,27 @@ export const KanbanCard = memo(function KanbanCard({
currentProject: state.currentProject,
}))
);
// A card should only display as "actively running" if it's both in the
// runningAutoTasks list AND in an execution-compatible status. Cards in resting
// states (backlog, ready, waiting_approval, verified, completed) should never
// show running controls, even if they appear in runningAutoTasks due to stale
// state (e.g., after a server restart that reconciled features back to backlog).
// A card should display as "actively running" if it's in the runningAutoTasks list
// AND in an execution-compatible status. However, there's a race window where a feature
// is tracked as running (in runningAutoTasks) but its disk/UI status hasn't caught up yet
// (still 'backlog', 'ready', or 'interrupted'). In this case, we still want to show
// running controls (Logs/Stop) and animated border, but not the full "actively running"
// state that gates all UI behavior.
const isInExecutionState =
feature.status === 'in_progress' ||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState;
// isRunningWithStaleStatus: feature is tracked as running but status hasn't updated yet.
// This happens during the timing gap between when the server starts a feature and when
// the UI receives the status update. Show running UI to prevent "Make" button flash.
const isRunningWithStaleStatus =
!!isCurrentAutoTask &&
!isInExecutionState &&
(feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted');
// Show running visual treatment for both fully confirmed and stale-status running tasks
const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus;
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
@@ -135,6 +147,7 @@ export const KanbanCard = memo(function KanbanCard({
const isDraggable =
!isSelectionMode &&
!isRunningWithStaleStatus &&
(feature.status === 'backlog' ||
feature.status === 'interrupted' ||
feature.status === 'ready' ||
@@ -198,13 +211,13 @@ export const KanbanCard = memo(function KanbanCard({
'kanban-card-content h-full relative',
reduceEffects ? 'shadow-none' : 'shadow-sm',
'transition-all duration-200 ease-out',
// Disable hover translate for in-progress cards to prevent gap showing gradient
// Disable hover translate for running cards to prevent gap showing gradient
isInteractive &&
!reduceEffects &&
!isActivelyRunning &&
!showRunningVisuals &&
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isActivelyRunning &&
!showRunningVisuals &&
cardBorderEnabled &&
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
@@ -221,7 +234,7 @@ export const KanbanCard = memo(function KanbanCard({
const renderCardContent = () => (
<Card
style={isActivelyRunning ? undefined : cardStyle}
style={showRunningVisuals ? undefined : cardStyle}
className={innerCardClasses}
onDoubleClick={isSelectionMode ? undefined : onEdit}
onClick={handleCardClick}
@@ -290,6 +303,7 @@ export const KanbanCard = memo(function KanbanCard({
<CardActions
feature={feature}
isCurrentAutoTask={isActivelyRunning}
isRunningTask={!!isCurrentAutoTask}
hasContext={hasContext}
shortcutKey={shortcutKey}
isSelectionMode={isSelectionMode}
@@ -316,7 +330,7 @@ export const KanbanCard = memo(function KanbanCard({
className={wrapperClasses}
data-testid={`kanban-card-${feature.id}`}
>
{isActivelyRunning ? (
{showRunningVisuals ? (
<div className="animated-border-wrapper">{renderCardContent()}</div>
) : (
renderCardContent()

View File

@@ -42,7 +42,12 @@ export const KanbanColumn = memo(function KanbanColumn({
contentStyle,
disableItemSpacing = false,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
const { setNodeRef, isOver: isColumnOver } = useDroppable({ id });
// Also make the header explicitly a drop target so dragging to the top of the column works
const { setNodeRef: setHeaderDropRef, isOver: isHeaderOver } = useDroppable({
id: `column-header-${id}`,
});
const isOver = isColumnOver || isHeaderOver;
// Use inline style for width if provided, otherwise use default w-72
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
@@ -70,8 +75,9 @@ export const KanbanColumn = memo(function KanbanColumn({
style={{ opacity: opacity / 100 }}
/>
{/* Column Header */}
{/* Column Header - also registered as a drop target so dragging to the header area works */}
<div
ref={setHeaderDropRef}
className={cn(
'relative z-10 flex items-center gap-3 px-3 py-2.5',
showBorder && 'border-b border-border/40'

View File

@@ -209,15 +209,22 @@ export const ListRow = memo(function ListRow({
blockingDependencies = [],
className,
}: ListRowProps) {
// A row should only display as "actively running" if it's both in the
// runningAutoTasks list AND in an execution-compatible status. Features in resting
// states (backlog, ready, waiting_approval, verified, completed) should never
// show running controls, even if they appear in runningAutoTasks due to stale
// state (e.g., after a server restart that reconciled features back to backlog).
// A row should display as "actively running" if it's in the runningAutoTasks list
// AND in an execution-compatible status. However, there's a race window where a feature
// is tracked as running but its status hasn't caught up yet (still 'backlog', 'ready',
// or 'interrupted'). We handle this with isRunningWithStaleStatus.
const isInExecutionState =
feature.status === 'in_progress' ||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
const isActivelyRunning = isCurrentAutoTask && isInExecutionState;
// Feature is tracked as running but status hasn't updated yet - show running UI
const isRunningWithStaleStatus =
isCurrentAutoTask &&
!isInExecutionState &&
(feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted');
const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus;
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
@@ -268,7 +275,7 @@ export const ListRow = memo(function ListRow({
>
{/* Checkbox column */}
{showCheckbox && (
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
<div role="cell" className="flex items-center justify-center w-10 px-2 py-2 shrink-0">
<input
type="checkbox"
checked={isSelected}
@@ -287,7 +294,7 @@ export const ListRow = memo(function ListRow({
<div
role="cell"
className={cn(
'flex items-center pl-3 pr-0 py-3 gap-0',
'flex items-center pl-3 pr-0 py-2 gap-0',
getColumnWidth('title'),
getColumnAlign('title')
)}
@@ -296,7 +303,7 @@ export const ListRow = memo(function ListRow({
<div className="flex items-center">
<span
className={cn(
'font-medium truncate',
'text-sm font-medium truncate',
feature.titleGenerating && !feature.title && 'animate-pulse text-muted-foreground'
)}
title={feature.title || feature.description}
@@ -325,7 +332,7 @@ export const ListRow = memo(function ListRow({
<div
role="cell"
className={cn(
'flex items-center pl-0 pr-3 py-3 shrink-0',
'flex items-center pl-0 pr-3 py-2 shrink-0',
getColumnWidth('priority'),
getColumnAlign('priority')
)}
@@ -358,14 +365,19 @@ export const ListRow = memo(function ListRow({
</div>
{/* Actions column */}
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isActivelyRunning} />
<div role="cell" className="flex items-center justify-end px-3 py-2 w-[80px] shrink-0">
<RowActions
feature={feature}
handlers={handlers}
isCurrentAutoTask={isActivelyRunning}
isRunningTask={!!isCurrentAutoTask}
/>
</div>
</div>
);
// Wrap with animated border for currently running auto task
if (isActivelyRunning) {
// Wrap with animated border for currently running auto task (including stale status)
if (showRunningVisuals) {
return <div className="animated-border-wrapper-row">{rowContent}</div>;
}

View File

@@ -60,6 +60,8 @@ export interface RowActionsProps {
handlers: RowActionHandlers;
/** Whether this feature is the current auto task (agent is running) */
isCurrentAutoTask?: boolean;
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
isRunningTask?: boolean;
/** Whether the dropdown menu is open */
isOpen?: boolean;
/** Callback when the dropdown open state changes */
@@ -115,7 +117,8 @@ const MenuItem = memo(function MenuItem({
function getPrimaryAction(
feature: Feature,
handlers: RowActionHandlers,
isCurrentAutoTask: boolean
isCurrentAutoTask: boolean,
isRunningTask: boolean = false
): {
icon: React.ComponentType<{ className?: string }>;
label: string;
@@ -135,6 +138,24 @@ function getPrimaryAction(
return null;
}
// Running task with stale status - show stop instead of Make
// This handles the race window where the feature is tracked as running
// but status hasn't updated to in_progress yet
if (
isRunningTask &&
(feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted') &&
handlers.onForceStop
) {
return {
icon: StopCircle,
label: 'Stop',
onClick: handlers.onForceStop,
variant: 'destructive',
};
}
// Backlog - implement is primary
if (feature.status === 'backlog' && handlers.onImplement) {
return {
@@ -263,6 +284,7 @@ export const RowActions = memo(function RowActions({
feature,
handlers,
isCurrentAutoTask = false,
isRunningTask = false,
isOpen,
onOpenChange,
className,
@@ -286,7 +308,7 @@ export const RowActions = memo(function RowActions({
[setOpen]
);
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask, isRunningTask);
const secondaryActions = getSecondaryActions(feature, handlers);
// Helper to close menu after action
@@ -403,7 +425,7 @@ export const RowActions = memo(function RowActions({
)}
{/* Backlog actions */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
{!isCurrentAutoTask && !isRunningTask && feature.status === 'backlog' && (
<>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{feature.planSpec?.content && handlers.onViewPlan && (

View File

@@ -493,7 +493,7 @@ export function CherryPickDialog({
if (step === 'select-commits') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-foreground" />

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/select';
import {
GitCommit,
GitMerge,
Sparkles,
FilePlus,
FileX,
@@ -36,7 +37,7 @@ import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import type { FileStatus } from '@/types/electron';
import type { FileStatus, MergeStateInfo } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface RemoteInfo {
@@ -116,6 +117,27 @@ const getStatusBadgeColor = (status: string) => {
}
};
const getMergeTypeLabel = (mergeType?: string) => {
switch (mergeType) {
case 'both-modified':
return 'Both Modified';
case 'added-by-us':
return 'Added by Us';
case 'added-by-them':
return 'Added by Them';
case 'deleted-by-us':
return 'Deleted by Us';
case 'deleted-by-them':
return 'Deleted by Them';
case 'both-added':
return 'Both Added';
case 'both-deleted':
return 'Both Deleted';
default:
return 'Merge';
}
};
function DiffLine({
type,
content,
@@ -190,6 +212,7 @@ export function CommitWorktreeDialog({
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
const [mergeState, setMergeState] = useState<MergeStateInfo | undefined>(undefined);
// Push after commit state
const [pushAfterCommit, setPushAfterCommit] = useState(false);
@@ -274,6 +297,7 @@ export function CommitWorktreeDialog({
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
setMergeState(undefined);
// Reset push state
setPushAfterCommit(false);
setRemotes([]);
@@ -292,8 +316,20 @@ export function CommitWorktreeDialog({
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
// Sort merge-affected files first when a merge is in progress
if (result.mergeState?.isMerging) {
const mergeSet = new Set(result.mergeState.mergeAffectedFiles);
fileList.sort((a, b) => {
const aIsMerge = mergeSet.has(a.path) || (a.isMergeAffected ?? false);
const bIsMerge = mergeSet.has(b.path) || (b.isMergeAffected ?? false);
if (aIsMerge && !bIsMerge) return -1;
if (!aIsMerge && bIsMerge) return 1;
return 0;
});
}
if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? '');
if (!cancelled) setMergeState(result.mergeState);
// If any files are already staged, pre-select only staged files
// Otherwise select all files by default
const stagedFiles = fileList.filter((f) => {
@@ -579,6 +615,34 @@ export function CommitWorktreeDialog({
</DialogHeader>
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
{/* Merge state banner */}
{mergeState?.isMerging && (
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-purple-400">
{mergeState.mergeOperationType === 'cherry-pick'
? 'Cherry-pick'
: mergeState.mergeOperationType === 'rebase'
? 'Rebase'
: 'Merge'}{' '}
in progress
</span>
{mergeState.conflictFiles.length > 0 ? (
<span className="text-purple-400/80 ml-1">
&mdash; {mergeState.conflictFiles.length} file
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
</span>
) : mergeState.isCleanMerge ? (
<span className="text-purple-400/80 ml-1">
&mdash; Clean merge, {mergeState.mergeAffectedFiles.length} file
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
</span>
) : null}
</div>
</div>
)}
{/* File Selection */}
<div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-1.5">
@@ -625,13 +689,25 @@ export function CommitWorktreeDialog({
const isStaged = idx !== ' ' && idx !== '?';
const isUnstaged = wt !== ' ' && wt !== '?';
const isUntracked = idx === '?' && wt === '?';
const isMergeFile =
file.isMergeAffected ||
(mergeState?.mergeAffectedFiles?.includes(file.path) ?? false);
return (
<div key={file.path} className="border-b border-border last:border-b-0">
<div
key={file.path}
className={cn(
'border-b last:border-b-0',
isMergeFile ? 'border-purple-500/30' : 'border-border'
)}
>
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
isExpanded && 'bg-accent/30'
'flex items-center gap-2 px-3 py-1.5 transition-colors group',
isMergeFile
? 'bg-purple-500/5 hover:bg-purple-500/10'
: 'hover:bg-accent/50',
isExpanded && (isMergeFile ? 'bg-purple-500/10' : 'bg-accent/30')
)}
>
{/* Checkbox */}
@@ -651,11 +727,21 @@ export function CommitWorktreeDialog({
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
{getFileIcon(file.status)}
{isMergeFile ? (
<GitMerge className="w-3.5 h-3.5 text-purple-500 flex-shrink-0" />
) : (
getFileIcon(file.status)
)}
<TruncatedFilePath
path={file.path}
className="text-xs font-mono flex-1 text-foreground"
/>
{isMergeFile && (
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0 bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-0.5">
<GitMerge className="w-2.5 h-2.5" />
{getMergeTypeLabel(file.mergeType)}
</span>
)}
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
@@ -810,11 +896,16 @@ export function CommitWorktreeDialog({
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate w-full block">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
<span className="ml-2 text-muted-foreground text-xs inline-block truncate max-w-[200px] align-bottom">
{remote.url}
</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -17,11 +17,17 @@ import {
FileWarning,
Wrench,
Sparkles,
GitMerge,
GitCommitHorizontal,
FileText,
Settings,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Checkbox } from '@/components/ui/checkbox';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { MergeConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo {
@@ -37,6 +43,7 @@ type PullPhase =
| 'local-changes' // Local changes detected, asking user what to do
| 'pulling' // Actively pulling (with or without stash)
| 'success' // Pull completed successfully
| 'merge-complete' // Pull resulted in a merge (not fast-forward, no conflicts)
| 'conflict' // Merge conflicts detected
| 'error'; // Something went wrong
@@ -53,6 +60,9 @@ interface PullResult {
stashed?: boolean;
stashRestored?: boolean;
stashRecoveryFailed?: boolean;
isMerge?: boolean;
isFastForward?: boolean;
mergeAffectedFiles?: string[];
}
interface GitPullDialogProps {
@@ -62,6 +72,8 @@ interface GitPullDialogProps {
remote?: string;
onPulled?: () => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Called when user chooses to commit the merge — opens the commit dialog */
onCommitMerge?: (worktree: { path: string; branch: string; isMain: boolean }) => void;
}
export function GitPullDialog({
@@ -71,10 +83,54 @@ export function GitPullDialog({
remote,
onPulled,
onCreateConflictResolutionFeature,
onCommitMerge,
}: GitPullDialogProps) {
const [phase, setPhase] = useState<PullPhase>('checking');
const [pullResult, setPullResult] = useState<PullResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [rememberChoice, setRememberChoice] = useState(false);
const [showMergeFiles, setShowMergeFiles] = useState(false);
const mergePostAction = useAppStore((s) => s.mergePostAction);
const setMergePostAction = useAppStore((s) => s.setMergePostAction);
/**
* Determine the appropriate phase after a successful pull.
* If the pull resulted in a merge (not fast-forward) and no conflicts,
* check user preference before deciding whether to show merge prompt.
*/
const handleSuccessfulPull = useCallback(
(result: PullResult) => {
setPullResult(result);
if (result.isMerge && !result.hasConflicts) {
// Merge happened — check user preference
if (mergePostAction === 'commit') {
// User preference: auto-commit
setPhase('success');
onPulled?.();
// Auto-trigger commit dialog
if (worktree && onCommitMerge) {
onCommitMerge(worktree);
onOpenChange(false);
}
} else if (mergePostAction === 'manual') {
// User preference: manual review
setPhase('success');
onPulled?.();
} else {
// No preference — show merge prompt; onPulled will be called from the
// user-action handlers (handleCommitMerge / handleMergeManually) once
// the user makes their choice, consistent with the conflict phase.
setPhase('merge-complete');
}
} else {
setPhase('success');
onPulled?.();
}
},
[mergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]
);
const checkForLocalChanges = useCallback(async () => {
if (!worktree) return;
@@ -103,9 +159,7 @@ export function GitPullDialog({
setPhase('local-changes');
} else if (result.result?.pulled !== undefined) {
// No local changes, pull went through (or already up to date)
setPullResult(result.result);
setPhase('success');
onPulled?.();
handleSuccessfulPull(result.result);
} else {
// Unexpected response: success but no recognizable fields
setPullResult(result.result ?? null);
@@ -116,18 +170,33 @@ export function GitPullDialog({
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
setPhase('error');
}
}, [worktree, remote, onPulled]);
}, [worktree, remote, handleSuccessfulPull]);
// Reset state when dialog opens
// Keep a ref to the latest checkForLocalChanges to break the circular dependency
// between the "reset/start" effect and the callback chain. Without this, any
// change in onPulled (passed from the parent) would recreate handleSuccessfulPull
// → checkForLocalChanges → re-trigger the effect while the dialog is already open,
// causing the pull flow to restart unintentionally.
const checkForLocalChangesRef = useRef(checkForLocalChanges);
useEffect(() => {
checkForLocalChangesRef.current = checkForLocalChanges;
});
// Reset state when dialog opens and start the initial pull check.
// Depends only on `open` and `worktree` — NOT on `checkForLocalChanges` —
// so that parent callback re-creations don't restart the pull flow mid-flight.
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
setRememberChoice(false);
setShowMergeFiles(false);
// Start the initial check using the ref so we always call the latest version
// without making it a dependency of this effect.
checkForLocalChangesRef.current();
}
}, [open, worktree, checkForLocalChanges]);
}, [open, worktree]);
const handlePullWithStash = useCallback(async () => {
if (!worktree) return;
@@ -155,8 +224,7 @@ export function GitPullDialog({
if (result.result?.hasConflicts) {
setPhase('conflict');
} else if (result.result?.pulled) {
setPhase('success');
onPulled?.();
handleSuccessfulPull(result.result);
} else {
// Unrecognized response: no pulled flag and no conflicts
console.warn('handlePullWithStash: unrecognized response', result.result);
@@ -167,7 +235,7 @@ export function GitPullDialog({
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
setPhase('error');
}
}, [worktree, remote, onPulled]);
}, [worktree, remote, handleSuccessfulPull]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
@@ -186,6 +254,35 @@ export function GitPullDialog({
onOpenChange(false);
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
const handleCommitMerge = useCallback(() => {
if (!worktree || !onCommitMerge) {
// No handler available — show feedback and bail without persisting preference
toast.error('Commit merge is not available', {
description: 'The commit merge action is not configured for this context.',
duration: 4000,
});
return;
}
if (rememberChoice) {
setMergePostAction('commit');
}
onPulled?.();
onCommitMerge(worktree);
onOpenChange(false);
}, [rememberChoice, setMergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]);
const handleMergeManually = useCallback(() => {
if (rememberChoice) {
setMergePostAction('manual');
}
toast.info('Merge left for manual review', {
description: 'Review the merged files and commit when ready.',
duration: 5000,
});
onPulled?.();
onOpenChange(false);
}, [rememberChoice, setMergePostAction, onPulled, onOpenChange]);
const handleClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
@@ -336,6 +433,137 @@ export function GitPullDialog({
</>
)}
{/* Merge Complete Phase — post-merge prompt */}
{phase === 'merge-complete' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Merge Complete
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
Pull resulted in a merge on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
<span>
{' '}
affecting {pullResult.mergeAffectedFiles.length} file
{pullResult.mergeAffectedFiles.length !== 1 ? 's' : ''}
</span>
)}
. How would you like to proceed?
</span>
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
<div>
<button
onClick={() => setShowMergeFiles(!showMergeFiles)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
<FileText className="w-3 h-3" />
{showMergeFiles ? 'Hide' : 'Show'} affected files (
{pullResult.mergeAffectedFiles.length})
</button>
{showMergeFiles && (
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
{pullResult.mergeAffectedFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
)}
</div>
)}
{pullResult?.stashed &&
pullResult?.stashRestored &&
!pullResult?.stashRecoveryFailed && (
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-green-600 dark:text-green-400 text-sm">
Your stashed changes have been restored successfully.
</span>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to proceed:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Commit Merge</strong> &mdash; Open the commit dialog with a merge
commit message
</li>
<li>
<strong>Review Manually</strong> &mdash; Leave the working tree as-is for
manual review
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
{/* Remember choice option */}
<div className="flex items-center gap-2 px-1">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={rememberChoice}
onCheckedChange={(checked) => setRememberChoice(checked === true)}
className="rounded border-border"
/>
<Settings className="w-3 h-3" />
Remember my choice for future merges
</label>
{(rememberChoice || mergePostAction) && (
<span className="text-xs text-muted-foreground ml-auto flex items-center gap-2">
<span className="opacity-70">
Current:{' '}
{mergePostAction === 'commit'
? 'auto-commit'
: mergePostAction === 'manual'
? 'manual review'
: 'ask every time'}
</span>
<button
onClick={() => {
setMergePostAction(null);
setRememberChoice(false);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset preference
</button>
</span>
)}
</div>
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
<FileText className="w-4 h-4 mr-2" />
Review Manually
</Button>
{worktree && onCommitMerge && (
<Button
onClick={handleCommitMerge}
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
>
<GitCommitHorizontal className="w-4 h-4 mr-2" />
Commit Merge
</Button>
)}
</DialogFooter>
</>
)}
{/* Conflict Phase */}
{phase === 'conflict' && (
<>

View File

@@ -0,0 +1,190 @@
/**
* Post-Merge Prompt Dialog
*
* Shown after a pull or stash apply results in a clean merge (no conflicts).
* Presents the user with two options:
* 1. Commit the merge — automatically stage all merge-result files and open commit dialog
* 2. Merge manually — leave the working tree as-is for manual review
*
* The user's choice can be persisted as a preference to avoid repeated prompts.
*/
import { useState, useCallback, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { GitMerge, GitCommitHorizontal, FileText, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
export type MergePostAction = 'commit' | 'manual' | null;
interface PostMergePromptDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Branch name where the merge happened */
branchName: string;
/** Number of files affected by the merge */
mergeFileCount: number;
/** List of files affected by the merge */
mergeAffectedFiles?: string[];
/** Called when the user chooses to commit the merge */
onCommitMerge: () => void;
/** Called when the user chooses to handle the merge manually */
onMergeManually: () => void;
/** Current saved preference (null = ask every time) */
savedPreference?: MergePostAction;
/** Called when the user changes the preference */
onSavePreference?: (preference: MergePostAction) => void;
}
export function PostMergePromptDialog({
open,
onOpenChange,
branchName,
mergeFileCount,
mergeAffectedFiles,
onCommitMerge,
onMergeManually,
savedPreference,
onSavePreference,
}: PostMergePromptDialogProps) {
const [rememberChoice, setRememberChoice] = useState(false);
const [showFiles, setShowFiles] = useState(false);
// Reset transient state each time the dialog is opened
useEffect(() => {
if (open) {
setRememberChoice(false);
setShowFiles(false);
}
}, [open]);
const handleCommitMerge = useCallback(() => {
if (rememberChoice && onSavePreference) {
onSavePreference('commit');
}
onCommitMerge();
onOpenChange(false);
}, [rememberChoice, onSavePreference, onCommitMerge, onOpenChange]);
const handleMergeManually = useCallback(() => {
if (rememberChoice && onSavePreference) {
onSavePreference('manual');
}
onMergeManually();
onOpenChange(false);
}, [rememberChoice, onSavePreference, onMergeManually, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px] w-full max-w-full sm:rounded-xl rounded-none dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Merge Complete
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
A merge was successfully completed on{' '}
<code className="font-mono bg-muted px-1 rounded">{branchName}</code>
{mergeFileCount > 0 && (
<span>
{' '}
affecting {mergeFileCount} file{mergeFileCount !== 1 ? 's' : ''}
</span>
)}
. How would you like to proceed?
</span>
{mergeAffectedFiles && mergeAffectedFiles.length > 0 && (
<div>
<button
onClick={() => setShowFiles(!showFiles)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
<FileText className="w-3 h-3" />
{showFiles ? 'Hide' : 'Show'} affected files ({mergeAffectedFiles.length})
</button>
{showFiles && (
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
{mergeAffectedFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
)}
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to proceed:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Commit Merge</strong> &mdash; Stage all merge files and open the commit
dialog with a pre-populated merge commit message
</li>
<li>
<strong>Review Manually</strong> &mdash; Leave the working tree as-is so you can
review changes and commit at your own pace
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
{/* Remember choice option */}
{onSavePreference && (
<div className="flex items-center gap-2 px-1">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={rememberChoice}
onCheckedChange={(checked) => setRememberChoice(checked)}
className="rounded border-border"
/>
<Settings className="w-3 h-3" />
Remember my choice for future merges
</label>
{savedPreference && (
<button
onClick={() => onSavePreference(null)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
>
Reset preference
</button>
)}
</div>
)}
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
<FileText className="w-4 h-4 mr-2" />
Review Manually
</Button>
<Button
onClick={handleCommitMerge}
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
>
<GitCommitHorizontal className="w-4 h-4 mr-2" />
Commit Merge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -251,7 +251,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />
@@ -263,7 +263,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{isLoading && (
<div className="flex items-center justify-center py-12">

View File

@@ -367,7 +367,7 @@ export function ViewStashesDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5" />

View File

@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
@@ -54,7 +54,7 @@ export function ViewWorktreeChangesDialog({
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
<GitDiffPanel
projectPath={projectPath}

View File

@@ -94,8 +94,6 @@ export function useBoardActions({
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
getAutoModeState,
getMaxConcurrencyForWorktree,
} = useAppStore();
const autoMode = useAutoMode();
@@ -561,38 +559,9 @@ export function useBoardActions({
const handleStartImplementation = useCallback(
async (feature: Feature) => {
// Check capacity for the feature's specific worktree, not the current view
// Normalize the branch name: if the feature's branch is the primary worktree branch,
// treat it as null (main worktree) to match how running tasks are stored
const rawBranchName = feature.branchName ?? null;
const featureBranchName =
currentProject?.path &&
rawBranchName &&
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
? null
: rawBranchName;
const featureWorktreeState = currentProject
? getAutoModeState(currentProject.id, featureBranchName)
: null;
// Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency
// instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1)
const featureMaxConcurrency = currentProject
? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName)
: autoMode.maxConcurrency;
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
if (!canStartInWorktree) {
const worktreeDesc = featureBranchName
? `worktree "${featureBranchName}"`
: 'main worktree';
toast.error('Concurrency limit reached', {
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
featureMaxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
}
// Note: No concurrency limit check here. Manual feature starts should never
// be blocked by the auto mode concurrency limit. The concurrency limit only
// governs how many features the auto-loop picks up automatically.
// Check for blocking dependencies and show warning if enabled
if (enableDependencyBlocking) {
@@ -681,18 +650,7 @@ export function useBoardActions({
return false;
}
},
[
autoMode,
enableDependencyBlocking,
features,
updateFeature,
persistFeatureUpdate,
handleRunFeature,
currentProject,
getAutoModeState,
getMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
]
[enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
);
const handleVerifyFeature = useCallback(

View File

@@ -163,13 +163,22 @@ export function useBoardDragDrop({
let targetStatus: ColumnId | null = null;
// Normalize the over ID: strip 'column-header-' prefix if the card was dropped
// directly onto the column header droppable zone (e.g. 'column-header-backlog' → 'backlog')
const effectiveOverId = overId.startsWith('column-header-')
? overId.replace('column-header-', '')
: overId;
// Check if we dropped on a column
const column = COLUMNS.find((c) => c.id === overId);
const column = COLUMNS.find((c) => c.id === effectiveOverId);
if (column) {
targetStatus = column.id;
} else if (effectiveOverId.startsWith('pipeline_')) {
// Pipeline step column (not in static COLUMNS list)
targetStatus = effectiveOverId as ColumnId;
} else {
// Dropped on another feature - find its column
const overFeature = features.find((f) => f.id === overId);
const overFeature = features.find((f) => f.id === effectiveOverId);
if (overFeature) {
targetStatus = overFeature.status;
}

View File

@@ -136,7 +136,7 @@ export function DevServerLogsPanel({
compact
>
{/* Compact Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base">
<Terminal className="w-4 h-4 text-primary" />

View File

@@ -354,12 +354,19 @@ export function WorktreeActionsDropdown({
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port})
{devServerInfo?.urlDetected === false
? 'Dev Server Starting...'
: `Dev Server Running (:${devServerInfo?.port})`}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
disabled={devServerInfo?.urlDetected === false}
aria-label={
devServerInfo?.urlDetected === false
? 'Open dev server in browser'
: `Open dev server on port ${devServerInfo?.port} in browser`
}
>
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
Open in Browser

View File

@@ -308,7 +308,11 @@ export function WorktreeDropdown({
{selectedStatus.devServerRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
title={
selectedStatus.devServerInfo?.urlDetected === false
? 'Dev server starting...'
: `Dev server running on port ${selectedStatus.devServerInfo?.port}`
}
>
<Globe className="w-3 h-3" />
</span>

View File

@@ -206,6 +206,16 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
}));
break;
}
case 'dev-server:url-detected': {
const { payload } = event;
logger.info('Dev server URL detected:', payload);
setState((prev) => ({
...prev,
url: payload.url,
port: payload.port,
}));
break;
}
}
});

View File

@@ -25,7 +25,10 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server);
serversMap.set(normalizePath(server.worktreePath), {
...server,
urlDetected: server.urlDetected ?? true,
});
}
setRunningDevServers(serversMap);
}
@@ -38,6 +41,39 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
fetchDevServers();
}, [fetchDevServers]);
// Subscribe to url-detected events to update port/url when the actual dev server port is detected
useEffect(() => {
const api = getElectronAPI();
if (!api?.worktree?.onDevServerLogEvent) return;
const unsubscribe = api.worktree.onDevServerLogEvent((event) => {
if (event.type === 'dev-server:url-detected') {
const { worktreePath, url, port } = event.payload;
const key = normalizePath(worktreePath);
let didUpdate = false;
setRunningDevServers((prev) => {
const existing = prev.get(key);
if (!existing) return prev;
const next = new Map(prev);
next.set(key, {
...existing,
url,
port,
urlDetected: true,
});
didUpdate = true;
return next;
});
if (didUpdate) {
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
toast.success(`Dev server running on port ${port}`);
}
}
});
return unsubscribe;
}, []);
const getWorktreeKey = useCallback(
(worktree: WorktreeInfo) => {
const path = worktree.isMain ? projectPath : worktree.path;
@@ -68,10 +104,11 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
urlDetected: false,
});
return next;
});
toast.success(`Dev server started on port ${result.result.port}`);
toast.success('Dev server started, detecting port...');
} else {
toast.error(result.error || 'Failed to start dev server');
}

View File

@@ -34,6 +34,8 @@ export interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
/** Whether the actual URL/port has been detected from server output */
urlDetected?: boolean;
}
export interface TestSessionInfo {

View File

@@ -646,57 +646,101 @@ export function WorktreePanel({
setPushToRemoteDialogOpen(true);
}, []);
// Keep a ref to pullDialogWorktree so handlePullCompleted can access the current
// value without including it in the dependency array. If pullDialogWorktree were
// a dep of handlePullCompleted, changing it would recreate the callback, which
// would propagate into GitPullDialog's onPulled prop and ultimately re-trigger
// the pull-check effect inside the dialog (causing the flow to run twice).
const pullDialogWorktreeRef = useRef(pullDialogWorktree);
useEffect(() => {
pullDialogWorktreeRef.current = pullDialogWorktree;
}, [pullDialogWorktree]);
// Handle pull completed - refresh branches and worktrees
const handlePullCompleted = useCallback(() => {
// Refresh branch data (ahead/behind counts, tracking) and worktree list
// after GitPullDialog completes the pull operation
if (pullDialogWorktree) {
fetchBranches(pullDialogWorktree.path);
if (pullDialogWorktreeRef.current) {
fetchBranches(pullDialogWorktreeRef.current.path);
}
fetchWorktrees({ silent: true });
}, [fetchWorktrees, fetchBranches, pullDialogWorktree]);
}, [fetchWorktrees, fetchBranches]);
// Wrapper for onCommit that works with the pull dialog's simpler WorktreeInfo.
// Uses the full pullDialogWorktree when available (via ref to avoid making it
// a dep that would cascade into handleSuccessfulPull → checkForLocalChanges recreations).
const handleCommitMerge = useCallback(
(_simpleWorktree: { path: string; branch: string; isMain: boolean }) => {
// Prefer the full worktree object we already have (from ref)
if (pullDialogWorktreeRef.current) {
onCommit(pullDialogWorktreeRef.current);
}
},
[onCommit]
);
// Handle pull with remote selection when multiple remotes exist
// Now opens the pull dialog which handles stash management and conflict resolution
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog first
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - open pull dialog directly with that remote
const remoteName = result.result.remotes[0].name;
setPullDialogRemote(remoteName);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
} else {
// No remotes - open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
} catch {
// If listing remotes fails, open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
}, []);
// Handle push with remote selection when multiple remotes exist
const handlePushWithRemoteSelection = useCallback(
// If the branch has a tracked remote, pull from it directly (skip the remote selection dialog)
const handlePullWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
// If the branch already tracks a remote, pull from it directly — no dialog needed
const tracked = getTrackingRemote(worktree.path);
if (tracked) {
setPullDialogRemote(tracked);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
return;
}
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog
// Multiple remotes and no tracking remote - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - open pull dialog directly with that remote
const remoteName = result.result.remotes[0].name;
setPullDialogRemote(remoteName);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
} else {
// No remotes - open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
} catch {
// If listing remotes fails, open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
},
[getTrackingRemote]
);
// Handle push with remote selection when multiple remotes exist
// If the branch has a tracked remote, push to it directly (skip the remote selection dialog)
const handlePushWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
// If the branch already tracks a remote, push to it directly — no dialog needed
const tracked = getTrackingRemote(worktree.path);
if (tracked) {
handlePush(worktree, tracked);
return;
}
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes and no tracking remote - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('push');
setSelectRemoteDialogOpen(true);
@@ -713,7 +757,7 @@ export function WorktreePanel({
handlePush(worktree);
}
},
[handlePush]
[handlePush, getTrackingRemote]
);
// Handle confirming remote selection for pull/push
@@ -992,6 +1036,7 @@ export function WorktreePanel({
remote={pullDialogRemote}
onPulled={handlePullCompleted}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
onCommitMerge={handleCommitMerge}
/>
{/* Dev Server Logs Panel */}
@@ -1445,6 +1490,7 @@ export function WorktreePanel({
onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree}
onStashApplied={handleStashApplied}
onStashApplyConflict={onStashApplyConflict}
/>
{/* Cherry Pick Dialog */}
@@ -1463,6 +1509,7 @@ export function WorktreePanel({
worktree={pullDialogWorktree}
remote={pullDialogRemote}
onPulled={handlePullCompleted}
onCommitMerge={handleCommitMerge}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div>