diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 065bbbd5..38712691 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -87,6 +87,8 @@ interface WorktreeInfo { conflictType?: 'merge' | 'rebase' | 'cherry-pick'; /** List of files with conflicts */ conflictFiles?: string[]; + /** Source branch involved in merge/rebase/cherry-pick, when resolvable */ + conflictSourceBranch?: string; } /** @@ -160,7 +162,7 @@ async function detectConflictState(worktreePath: string): Promise<{ 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') + (await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string ).trim(); try { const branchName = await execGitCommand( @@ -180,7 +182,7 @@ async function detectConflictState(worktreePath: string): Promise<{ ? path.join(gitDir, 'rebase-merge', 'onto-name') : path.join(gitDir, 'rebase-apply', 'onto-name'); try { - const ontoName = (await secureFs.readFile(headNamePath, 'utf-8')).trim(); + const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim(); if (ontoName) { conflictSourceBranch = ontoName.replace(/^refs\/heads\//, ''); } @@ -190,7 +192,7 @@ async function detectConflictState(worktreePath: string): Promise<{ const ontoPath = rebaseMergeExists ? path.join(gitDir, 'rebase-merge', 'onto') : path.join(gitDir, 'rebase-apply', 'onto'); - const ontoCommit = (await secureFs.readFile(ontoPath, 'utf-8')).trim(); + const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim(); if (ontoCommit) { const branchName = await execGitCommand( ['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit], @@ -208,7 +210,7 @@ async function detectConflictState(worktreePath: string): Promise<{ } 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') + (await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string ).trim(); try { const branchName = await execGitCommand( @@ -669,6 +671,7 @@ export function createListHandler() { // hasConflicts is true only when there are actual unresolved files worktree.hasConflicts = conflictState.hasConflicts; worktree.conflictFiles = conflictState.conflictFiles; + worktree.conflictSourceBranch = conflictState.conflictSourceBranch; } catch { // Ignore conflict detection errors } diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 04958da0..9b525edb 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -843,6 +843,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) { onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), onWorktreeAutoSelect: addAndSelectWorktree, currentWorktreeBranch, + stopFeature: autoMode.stopFeature, }); // Handler for bulk updating multiple features diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 5a20a48b..f2e84d14 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -14,7 +14,6 @@ import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/descri import { getElectronAPI } from '@/lib/electron'; import { isConnectionError, handleServerOffline, getHttpApiClient } from '@/lib/http-api-client'; import { toast } from 'sonner'; -import { useAutoMode } from '@/hooks/use-auto-mode'; import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations'; import { truncateDescription } from '@/lib/utils'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; @@ -86,6 +85,7 @@ interface UseBoardActionsProps { onWorktreeCreated?: () => void; onWorktreeAutoSelect?: (worktree: { path: string; branch: string }) => void; currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering + stopFeature: (featureId: string) => Promise; // Passed from parent's useAutoMode to avoid duplicate subscription } export function useBoardActions({ @@ -114,6 +114,7 @@ export function useBoardActions({ onWorktreeCreated, onWorktreeAutoSelect, currentWorktreeBranch, + stopFeature, }: UseBoardActionsProps) { const queryClient = useQueryClient(); @@ -130,7 +131,6 @@ export function useBoardActions({ const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode); const isPrimaryWorktreeBranch = useAppStore((s) => s.isPrimaryWorktreeBranch); const getPrimaryWorktreeBranch = useAppStore((s) => s.getPrimaryWorktreeBranch); - const autoMode = useAutoMode(); // React Query mutations for feature operations const verifyFeatureMutation = useVerifyFeature(currentProject?.path ?? ''); @@ -538,7 +538,7 @@ export function useBoardActions({ if (isRunning) { try { - await autoMode.stopFeature(featureId); + await stopFeature(featureId); // Remove from all worktrees if (currentProject) { removeRunningTaskFromAllWorktrees(currentProject.id, featureId); @@ -573,7 +573,7 @@ export function useBoardActions({ removeFeature(featureId); await persistFeatureDelete(featureId); }, - [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete, currentProject] + [features, runningAutoTasks, stopFeature, removeFeature, persistFeatureDelete, currentProject] ); const handleRunFeature = useCallback( @@ -1032,7 +1032,7 @@ export function useBoardActions({ const handleForceStopFeature = useCallback( async (feature: Feature) => { try { - await autoMode.stopFeature(feature.id); + await stopFeature(feature.id); const targetStatus = feature.skipTests && feature.status === 'waiting_approval' @@ -1040,7 +1040,7 @@ export function useBoardActions({ : 'backlog'; // Remove the running task from ALL worktrees for this project. - // autoMode.stopFeature only removes from its scoped worktree (branchName), + // 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. @@ -1085,7 +1085,7 @@ export function useBoardActions({ }); } }, - [autoMode, moveFeature, persistFeatureUpdate, currentProject, queryClient] + [stopFeature, moveFeature, persistFeatureUpdate, currentProject, queryClient] ); const handleStartNextFeatures = useCallback(async () => { @@ -1197,7 +1197,7 @@ export function useBoardActions({ if (runningVerified.length > 0) { await Promise.allSettled( runningVerified.map((feature) => - autoMode.stopFeature(feature.id).catch((error) => { + stopFeature(feature.id).catch((error) => { logger.error('Error stopping feature before archive:', error); }) ) @@ -1236,7 +1236,7 @@ export function useBoardActions({ // Reload features to sync state with server on error loadFeatures(); } - }, [features, runningAutoTasks, autoMode, updateFeature, currentProject, loadFeatures]); + }, [features, runningAutoTasks, stopFeature, updateFeature, currentProject, loadFeatures]); const handleDuplicateFeature = useCallback( async (feature: Feature, asChild: boolean = false) => { 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 cba34c85..b847be82 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 @@ -19,7 +19,7 @@ interface UseBoardDragDropProps { runningAutoTasks: string[]; persistFeatureUpdate: (featureId: string, updates: Partial) => Promise; handleStartImplementation: (feature: Feature) => Promise; - stopFeature: (featureId: string) => Promise; + stopFeature: (featureId: string) => Promise; } export function useBoardDragDrop({ @@ -256,8 +256,12 @@ export function useBoardDragDrop({ // If the feature is currently running, stop it first if (isRunningTask) { try { - await stopFeature(featureId); - logger.info('Stopped running feature via drag to backlog:', featureId); + const stopped = await stopFeature(featureId); + if (stopped) { + logger.info('Stopped running feature via drag to backlog:', featureId); + } else { + logger.warn('Feature was not running by the time stop was requested:', featureId); + } } catch (error) { logger.error('Error stopping feature during drag to backlog:', error); toast.error('Failed to stop agent', { 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 acca6f42..20960b8b 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 @@ -482,7 +482,7 @@ export function WorktreeActionsDropdown({ onCreateConflictResolutionFeature({ - sourceBranch: worktree.branch, + sourceBranch: worktree.conflictSourceBranch ?? worktree.branch, targetBranch: worktree.branch, targetWorktreePath: worktree.path, conflictFiles: worktree.conflictFiles, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index 9fc8da72..e352e5eb 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -17,6 +17,8 @@ export interface WorktreeInfo { conflictType?: 'merge' | 'rebase' | 'cherry-pick'; /** List of files with conflicts */ conflictFiles?: string[]; + /** The branch that is the source of the conflict (e.g. the branch being merged in) */ + conflictSourceBranch?: string; } export interface BranchInfo { diff --git a/apps/ui/src/components/views/graph-view-page.tsx b/apps/ui/src/components/views/graph-view-page.tsx index f429b37f..cba7bb1d 100644 --- a/apps/ui/src/components/views/graph-view-page.tsx +++ b/apps/ui/src/components/views/graph-view-page.tsx @@ -475,6 +475,7 @@ export function GraphViewPage() { setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch); }, currentWorktreeBranch, + stopFeature: autoMode.stopFeature, }); // Handle add and start feature diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts index 4c091005..5c5d03ea 100644 --- a/apps/ui/src/hooks/queries/use-worktrees.ts +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -28,6 +28,8 @@ interface WorktreeInfo { conflictType?: 'merge' | 'rebase' | 'cherry-pick'; /** List of files with conflicts */ conflictFiles?: string[]; + /** The branch that is the source of the conflict (e.g. the branch being merged in) */ + conflictSourceBranch?: string; } interface RemovedWorktree { diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index 7ae3301f..faf12343 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -959,10 +959,10 @@ export function useAutoMode(worktree?: WorktreeInfo) { // Stop a specific feature const stopFeature = useCallback( - async (featureId: string) => { + async (featureId: string): Promise => { if (!currentProject) { logger.error('No project selected'); - return; + return false; } try { @@ -983,6 +983,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { message: 'Feature stopped by user', passes: false, }); + return true; } else { logger.error('Failed to stop feature:', result.error); throw new Error(result.error || 'Failed to stop feature'); diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 403c31e3..6f5d0758 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -613,10 +613,7 @@ function RootLayoutContent() { // Reconcile ntfy endpoints from server (same rationale as eventHooks) const serverEndpoints = (finalSettings as GlobalSettings).ntfyEndpoints ?? []; const currentEndpoints = useAppStore.getState().ntfyEndpoints; - if ( - JSON.stringify(serverEndpoints) !== JSON.stringify(currentEndpoints) && - serverEndpoints.length > 0 - ) { + if (JSON.stringify(serverEndpoints) !== JSON.stringify(currentEndpoints)) { logger.info( `[FAST_HYDRATE] Reconciling ntfyEndpoints from server (server=${serverEndpoints.length}, store=${currentEndpoints.length})` ); diff --git a/apps/ui/tests/features/feature-deep-link.spec.ts b/apps/ui/tests/features/feature-deep-link.spec.ts index 7083c264..14d154a0 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 ({}, testInfo) => { + test.beforeEach(async (_, testInfo) => { projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`; projectPath = path.join(TEST_TEMP_DIR, projectName); fs.mkdirSync(projectPath, { recursive: true });