From c11f390764959fca94ec43528bc157fd8c94b696 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 2 Mar 2026 07:20:11 -0800 Subject: [PATCH] feat: Add conflict source branch detection and fix re-render cascade in BoardView --- .../server/src/routes/worktree/routes/list.ts | 75 +++++++++++++++++++ apps/ui/src/components/views/board-view.tsx | 31 +++++--- .../board-view/hooks/use-board-drag-drop.ts | 22 ++++-- .../components/worktree-actions-dropdown.tsx | 30 +++++++- .../components/worktree-dropdown.tsx | 5 ++ .../components/worktree-tab.tsx | 5 ++ .../worktree-panel/worktree-panel.tsx | 4 + .../tests/features/feature-deep-link.spec.ts | 2 +- 8 files changed, 158 insertions(+), 16 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 78bc7186..065bbbd5 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -98,6 +98,7 @@ async function detectConflictState(worktreePath: string): Promise<{ hasConflicts: boolean; conflictType?: 'merge' | 'rebase' | 'cherry-pick'; conflictFiles?: string[]; + conflictSourceBranch?: string; }> { try { // Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI) @@ -153,10 +154,84 @@ async function detectConflictState(worktreePath: string): Promise<{ // Fall back to empty list if diff fails } + // Detect the source branch involved in the conflict + let conflictSourceBranch: string | undefined; + try { + if (conflictType === 'merge' && mergeHeadExists) { + // For merges, resolve MERGE_HEAD to a branch name + const mergeHead = ( + await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8') + ).trim(); + try { + const branchName = await execGitCommand( + ['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead], + worktreePath + ); + const cleaned = branchName.trim().replace(/~\d+$/, ''); + if (cleaned && cleaned !== 'undefined') { + conflictSourceBranch = cleaned; + } + } catch { + // Could not resolve to branch name + } + } else if (conflictType === 'rebase') { + // For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name + const headNamePath = rebaseMergeExists + ? path.join(gitDir, 'rebase-merge', 'onto-name') + : path.join(gitDir, 'rebase-apply', 'onto-name'); + try { + const ontoName = (await secureFs.readFile(headNamePath, 'utf-8')).trim(); + if (ontoName) { + conflictSourceBranch = ontoName.replace(/^refs\/heads\//, ''); + } + } catch { + // onto-name may not exist; try to resolve the onto commit + try { + const ontoPath = rebaseMergeExists + ? path.join(gitDir, 'rebase-merge', 'onto') + : path.join(gitDir, 'rebase-apply', 'onto'); + const ontoCommit = (await secureFs.readFile(ontoPath, 'utf-8')).trim(); + if (ontoCommit) { + const branchName = await execGitCommand( + ['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit], + worktreePath + ); + const cleaned = branchName.trim().replace(/~\d+$/, ''); + if (cleaned && cleaned !== 'undefined') { + conflictSourceBranch = cleaned; + } + } + } catch { + // Could not resolve onto commit + } + } + } else if (conflictType === 'cherry-pick' && cherryPickHeadExists) { + // For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name + const cherryPickHead = ( + await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8') + ).trim(); + try { + const branchName = await execGitCommand( + ['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead], + worktreePath + ); + const cleaned = branchName.trim().replace(/~\d+$/, ''); + if (cleaned && cleaned !== 'undefined') { + conflictSourceBranch = cleaned; + } + } catch { + // Could not resolve to branch name + } + } + } catch { + // Ignore source branch detection errors + } + return { hasConflicts: conflictFiles.length > 0, conflictType, conflictFiles, + conflictSourceBranch, }; } catch { // If anything fails, assume no conflicts diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index f902b1bb..04958da0 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -715,15 +715,27 @@ export function BoardView({ initialFeatureId }: BoardViewProps) { const selectedWorktreeBranch = currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main'; - // Aggregate running auto tasks across all worktrees for this project - const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree); - const runningAutoTasksAllWorktrees = useMemo(() => { - if (!currentProject?.id) return []; - const prefix = `${currentProject.id}::`; - return Object.entries(autoModeByWorktree) - .filter(([key]) => key.startsWith(prefix)) - .flatMap(([, state]) => state.runningTasks ?? []); - }, [autoModeByWorktree, currentProject?.id]); + // Aggregate running auto tasks across all worktrees for this project. + // IMPORTANT: Use a derived selector with shallow equality instead of subscribing + // to the raw autoModeByWorktree object. The raw subscription caused the entire + // BoardView to re-render on EVERY auto-mode state change (any worktree), which + // during worktree switches cascaded through DndContext/KanbanBoard and triggered + // React error #185 (maximum update depth exceeded), crashing the board view. + const runningAutoTasksAllWorktrees = useAppStore( + useShallow((state) => { + if (!currentProject?.id) return [] as string[]; + const prefix = `${currentProject.id}::`; + const tasks: string[] = []; + for (const [key, worktreeState] of Object.entries(state.autoModeByWorktree)) { + if (key.startsWith(prefix) && worktreeState.runningTasks) { + for (const task of worktreeState.runningTasks) { + tasks.push(task); + } + } + } + return tasks; + }) + ); // Get in-progress features for keyboard shortcuts (needed before actions hook) // Must be after runningAutoTasks is defined @@ -1506,6 +1518,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) { runningAutoTasks: runningAutoTasksAllWorktrees, persistFeatureUpdate, handleStartImplementation, + stopFeature: autoMode.stopFeature, }); // Handle dependency link creation diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts index 34c9bd6b..cba34c85 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -1,9 +1,8 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; import { Feature } from '@/store/app-store'; import { useAppStore } from '@/store/app-store'; -import { useAutoMode } from '@/hooks/use-auto-mode'; import { toast } from 'sonner'; import { COLUMNS, ColumnId } from '../constants'; @@ -20,6 +19,7 @@ interface UseBoardDragDropProps { runningAutoTasks: string[]; persistFeatureUpdate: (featureId: string, updates: Partial) => Promise; handleStartImplementation: (feature: Feature) => Promise; + stopFeature: (featureId: string) => Promise; } export function useBoardDragDrop({ @@ -28,6 +28,7 @@ export function useBoardDragDrop({ runningAutoTasks, persistFeatureUpdate, handleStartImplementation, + stopFeature, }: UseBoardDragDropProps) { const [activeFeature, setActiveFeature] = useState(null); const [pendingDependencyLink, setPendingDependencyLink] = useState( @@ -39,11 +40,22 @@ export function useBoardDragDrop({ // and triggers React error #185 (maximum update depth exceeded). const moveFeature = useAppStore((s) => s.moveFeature); const updateFeature = useAppStore((s) => s.updateFeature); - const autoMode = useAutoMode(); // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side // at execution time based on feature.branchName + // Clear stale activeFeature when features list changes (e.g. during worktree switches). + // Without this, the DragOverlay in KanbanBoard can try to render a feature from + // a previous worktree, causing property access crashes. + useEffect(() => { + setActiveFeature((current) => { + if (!current) return null; + // If the active feature is no longer in the features list, clear it + const stillExists = features.some((f) => f.id === current.id); + return stillExists ? current : null; + }); + }, [features]); + const handleDragStart = useCallback( (event: DragStartEvent) => { const { active } = event; @@ -244,7 +256,7 @@ export function useBoardDragDrop({ // If the feature is currently running, stop it first if (isRunningTask) { try { - await autoMode.stopFeature(featureId); + await stopFeature(featureId); logger.info('Stopped running feature via drag to backlog:', featureId); } catch (error) { logger.error('Error stopping feature during drag to backlog:', error); @@ -339,7 +351,7 @@ export function useBoardDragDrop({ updateFeature, persistFeatureUpdate, handleStartImplementation, - autoMode, + stopFeature, ] ); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 215ecf50..acca6f42 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -46,11 +46,19 @@ import { ArrowLeftRight, Check, Hash, + Sparkles, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; -import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types'; +import type { + WorktreeInfo, + DevServerInfo, + PRInfo, + GitRepoStatus, + TestSessionInfo, + MergeConflictInfo, +} from '../types'; import { TooltipWrapper } from './tooltip-wrapper'; import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors'; import { @@ -137,6 +145,8 @@ interface WorktreeActionsDropdownProps { onAbortOperation?: (worktree: WorktreeInfo) => void; /** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */ onContinueOperation?: (worktree: WorktreeInfo) => void; + /** Create a feature to resolve merge/rebase/cherry-pick conflicts with AI */ + onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; hasInitScript: boolean; /** Terminal quick scripts configured for the project */ terminalScripts?: TerminalScript[]; @@ -293,6 +303,7 @@ export function WorktreeActionsDropdown({ onCherryPick, onAbortOperation, onContinueOperation, + onCreateConflictResolutionFeature, hasInitScript, terminalScripts, onRunTerminalScript, @@ -467,6 +478,23 @@ export function WorktreeActionsDropdown({ : 'Operation'} )} + {onCreateConflictResolutionFeature && ( + + onCreateConflictResolutionFeature({ + sourceBranch: worktree.branch, + targetBranch: worktree.branch, + targetWorktreePath: worktree.path, + conflictFiles: worktree.conflictFiles, + operationType: worktree.conflictType, + }) + } + className="text-xs text-purple-500 focus:text-purple-600" + > + + Resolve with AI + + )} )} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx index 5df375c9..315f225e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx @@ -27,6 +27,7 @@ import type { PRInfo, GitRepoStatus, TestSessionInfo, + MergeConflictInfo, } from '../types'; import { WorktreeDropdownItem } from './worktree-dropdown-item'; import { BranchSwitchDropdown } from './branch-switch-dropdown'; @@ -129,6 +130,8 @@ export interface WorktreeDropdownProps { onAbortOperation?: (worktree: WorktreeInfo) => void; /** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */ onContinueOperation?: (worktree: WorktreeInfo) => void; + /** Create a feature to resolve merge/rebase/cherry-pick conflicts with AI */ + onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; /** Remotes cache: maps worktree path to list of remotes */ remotesCache?: Record>; /** Pull from a specific remote, bypassing the remote selection dialog */ @@ -241,6 +244,7 @@ export function WorktreeDropdown({ onCherryPick, onAbortOperation, onContinueOperation, + onCreateConflictResolutionFeature, remotesCache, onPullWithRemote, onPushWithRemote, @@ -607,6 +611,7 @@ export function WorktreeDropdown({ onCherryPick={onCherryPick} onAbortOperation={onAbortOperation} onContinueOperation={onContinueOperation} + onCreateConflictResolutionFeature={onCreateConflictResolutionFeature} hasInitScript={hasInitScript} terminalScripts={terminalScripts} onRunTerminalScript={onRunTerminalScript} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 56e1acf1..20354ca6 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -12,6 +12,7 @@ import type { PRInfo, GitRepoStatus, TestSessionInfo, + MergeConflictInfo, } from '../types'; import { BranchSwitchDropdown } from './branch-switch-dropdown'; import { WorktreeActionsDropdown } from './worktree-actions-dropdown'; @@ -95,6 +96,8 @@ interface WorktreeTabProps { onAbortOperation?: (worktree: WorktreeInfo) => void; /** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */ onContinueOperation?: (worktree: WorktreeInfo) => void; + /** Create a feature to resolve merge/rebase/cherry-pick conflicts with AI */ + onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; hasInitScript: boolean; /** Whether a test command is configured in project settings */ hasTestCommand?: boolean; @@ -195,6 +198,7 @@ export function WorktreeTab({ onCherryPick, onAbortOperation, onContinueOperation, + onCreateConflictResolutionFeature, hasInitScript, hasTestCommand = false, remotes, @@ -579,6 +583,7 @@ export function WorktreeTab({ onCherryPick={onCherryPick} onAbortOperation={onAbortOperation} onContinueOperation={onContinueOperation} + onCreateConflictResolutionFeature={onCreateConflictResolutionFeature} hasInitScript={hasInitScript} terminalScripts={terminalScripts} onRunTerminalScript={onRunTerminalScript} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 7f7347b5..cb7c3f1d 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -1071,6 +1071,7 @@ export function WorktreePanel({ onCherryPick={handleCherryPick} onAbortOperation={handleAbortOperation} onContinueOperation={handleContinueOperation} + onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} hasInitScript={hasInitScript} terminalScripts={terminalScripts} onRunTerminalScript={handleRunTerminalScript} @@ -1310,6 +1311,7 @@ export function WorktreePanel({ onCherryPick={handleCherryPick} onAbortOperation={handleAbortOperation} onContinueOperation={handleContinueOperation} + onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} terminalScripts={terminalScripts} onRunTerminalScript={handleRunTerminalScript} onEditScripts={handleEditScripts} @@ -1391,6 +1393,7 @@ export function WorktreePanel({ onCherryPick={handleCherryPick} onAbortOperation={handleAbortOperation} onContinueOperation={handleContinueOperation} + onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} hasInitScript={hasInitScript} hasTestCommand={hasTestCommand} terminalScripts={terminalScripts} @@ -1478,6 +1481,7 @@ export function WorktreePanel({ onCherryPick={handleCherryPick} onAbortOperation={handleAbortOperation} onContinueOperation={handleContinueOperation} + onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} hasInitScript={hasInitScript} hasTestCommand={hasTestCommand} terminalScripts={terminalScripts} diff --git a/apps/ui/tests/features/feature-deep-link.spec.ts b/apps/ui/tests/features/feature-deep-link.spec.ts index 639c51f5..7083c264 100644 --- a/apps/ui/tests/features/feature-deep-link.spec.ts +++ b/apps/ui/tests/features/feature-deep-link.spec.ts @@ -28,7 +28,7 @@ test.describe('Feature Deep Link', () => { let projectPath: string; let projectName: string; - test.beforeEach(async (_fixtures, testInfo) => { + test.beforeEach(async ({}, testInfo) => { projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`; projectPath = path.join(TEST_TEMP_DIR, projectName); fs.mkdirSync(projectPath, { recursive: true });