Skip to content

Commit 26b73df

Browse files
Fix feature deep link with project path handling (#834)
* Changes from fix/feature-deeplink-worktree * Update apps/ui/src/components/views/board-view.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 20e7c74 commit 26b73df

8 files changed

Lines changed: 297 additions & 41 deletions

File tree

apps/server/src/services/event-hook-service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -595,12 +595,12 @@ export class EventHookService {
595595
if (clickUrl && context.projectPath) {
596596
try {
597597
const url = new URL(clickUrl);
598+
url.pathname = '/board';
599+
// Add projectPath so the UI can switch to the correct project
600+
url.searchParams.set('projectPath', context.projectPath);
598601
// Add featureId as query param for deep linking to board with feature output modal
599602
if (context.featureId) {
600-
url.pathname = '/board';
601603
url.searchParams.set('featureId', context.featureId);
602-
} else {
603-
url.pathname = '/board';
604604
}
605605
clickUrl = url.toString();
606606
} catch (error) {

apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,13 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
6868

6969
// Navigate to the relevant view based on notification type
7070
if (notification.featureId) {
71-
navigate({ to: '/board', search: { featureId: notification.featureId } });
71+
navigate({
72+
to: '/board',
73+
search: {
74+
featureId: notification.featureId,
75+
projectPath: notification.projectPath || undefined,
76+
},
77+
});
7278
}
7379
},
7480
[handleMarkAsRead, setPopoverOpen, navigate]

apps/ui/src/components/views/board-view.tsx

Lines changed: 118 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
1+
import { useEffect, useState, useCallback, useMemo, useRef, startTransition } from 'react';
22
import { createLogger } from '@automaker/utils/logger';
33
import type { PointerEvent as ReactPointerEvent } from 'react';
44
import {
@@ -37,6 +37,7 @@ import type {
3737
ReasoningEffort,
3838
} from '@automaker/types';
3939
import { pathsEqual } from '@/lib/utils';
40+
import { initializeProject } from '@/lib/project-init';
4041
import { toast } from 'sonner';
4142
import {
4243
BoardBackgroundModal,
@@ -117,9 +118,11 @@ const logger = createLogger('Board');
117118
interface BoardViewProps {
118119
/** Feature ID from URL parameter - if provided, opens output modal for this feature on load */
119120
initialFeatureId?: string;
121+
/** Project path from URL parameter - if provided, switches to this project before handling deep link */
122+
initialProjectPath?: string;
120123
}
121124

122-
export function BoardView({ initialFeatureId }: BoardViewProps) {
125+
export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewProps) {
123126
const {
124127
currentProject,
125128
defaultSkipTests,
@@ -139,6 +142,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
139142
setPipelineConfig,
140143
featureTemplates,
141144
defaultSortNewestCardOnTop,
145+
upsertAndSetCurrentProject,
142146
} = useAppStore(
143147
useShallow((state) => ({
144148
currentProject: state.currentProject,
@@ -159,6 +163,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
159163
setPipelineConfig: state.setPipelineConfig,
160164
featureTemplates: state.featureTemplates,
161165
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop,
166+
upsertAndSetCurrentProject: state.upsertAndSetCurrentProject,
162167
}))
163168
);
164169
// Also get keyboard shortcuts for the add feature shortcut
@@ -305,6 +310,53 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
305310
setFeaturesWithContext,
306311
});
307312

313+
// Handle deep link project switching - if URL includes a projectPath that differs from
314+
// the current project, switch to the target project first. The feature/worktree deep link
315+
// effect below will fire naturally once the project switch triggers a features reload.
316+
const handledProjectPathRef = useRef<string | undefined>(undefined);
317+
useEffect(() => {
318+
if (!initialProjectPath || handledProjectPathRef.current === initialProjectPath) {
319+
return;
320+
}
321+
322+
// Check if we're already on the correct project
323+
if (currentProject?.path && pathsEqual(currentProject.path, initialProjectPath)) {
324+
handledProjectPathRef.current = initialProjectPath;
325+
return;
326+
}
327+
328+
handledProjectPathRef.current = initialProjectPath;
329+
330+
const switchProject = async () => {
331+
try {
332+
const initResult = await initializeProject(initialProjectPath);
333+
if (!initResult.success) {
334+
logger.warn(
335+
`Deep link: failed to initialize project "${initialProjectPath}":`,
336+
initResult.error
337+
);
338+
toast.error('Failed to open project from link', {
339+
description: initResult.error || 'Unknown error',
340+
});
341+
return;
342+
}
343+
344+
// Derive project name from path basename
345+
const projectName =
346+
initialProjectPath.split(/[/\\]/).filter(Boolean).pop() || initialProjectPath;
347+
logger.info(`Deep link: switching to project "${projectName}" at ${initialProjectPath}`);
348+
upsertAndSetCurrentProject(initialProjectPath, projectName);
349+
} catch (error) {
350+
logger.error('Deep link: project switch failed:', error);
351+
toast.error('Failed to switch project', {
352+
description: error instanceof Error ? error.message : 'Unknown error',
353+
});
354+
}
355+
};
356+
357+
switchProject();
358+
}, [initialProjectPath, currentProject?.path, upsertAndSetCurrentProject]);
359+
308360
// Handle initial feature ID from URL - switch to the correct worktree and open output modal
309361
// Uses a ref to track which featureId has been handled to prevent re-opening
310362
// when the component re-renders but initialFeatureId hasn't changed.
@@ -325,6 +377,17 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
325377
[currentProject?.path]
326378
)
327379
);
380+
381+
// Track how many render cycles we've waited for worktrees during a deep link.
382+
// If the Zustand store never gets populated (e.g., WorktreePanel hasn't mounted,
383+
// useWorktrees setting is off, or the worktree query failed), we stop waiting
384+
// after a threshold and open the modal without switching worktree.
385+
const deepLinkRetryCountRef = useRef(0);
386+
// Reset retry count when the feature ID changes
387+
useEffect(() => {
388+
deepLinkRetryCountRef.current = 0;
389+
}, [initialFeatureId]);
390+
328391
useEffect(() => {
329392
if (
330393
!initialFeatureId ||
@@ -339,14 +402,43 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
339402
const feature = hookFeatures.find((f) => f.id === initialFeatureId);
340403
if (!feature) return;
341404

342-
// If the feature has a branch, wait for worktrees to load so we can switch
343-
if (feature.branchName && deepLinkWorktrees.length === 0) {
344-
return; // Worktrees not loaded yet - effect will re-run when they load
405+
// Resolve worktrees: prefer the Zustand store (reactive), but fall back to
406+
// the React Query cache if the store hasn't been populated yet. The store is
407+
// only synced by the WorktreePanel's useWorktrees hook, which may not have
408+
// rendered yet during a deep link cold start. Reading the query cache directly
409+
// avoids an indefinite wait that hangs the app on the loading screen.
410+
let resolvedWorktrees = deepLinkWorktrees;
411+
if (resolvedWorktrees.length === 0 && currentProject.path) {
412+
const cachedData = queryClient.getQueryData(queryKeys.worktrees.all(currentProject.path)) as
413+
| { worktrees?: WorktreeInfo[] }
414+
| undefined;
415+
if (cachedData?.worktrees && cachedData.worktrees.length > 0) {
416+
resolvedWorktrees = cachedData.worktrees as typeof deepLinkWorktrees;
417+
}
418+
}
419+
420+
// If the feature has a branch and worktrees aren't available yet, wait briefly.
421+
// After enough retries, proceed without switching worktree to avoid hanging.
422+
const MAX_DEEP_LINK_RETRIES = 10;
423+
if (feature.branchName && resolvedWorktrees.length === 0) {
424+
deepLinkRetryCountRef.current++;
425+
if (deepLinkRetryCountRef.current < MAX_DEEP_LINK_RETRIES) {
426+
return; // Worktrees not loaded yet - effect will re-run when they load
427+
}
428+
// Exceeded retry limit — proceed without worktree switch to avoid hanging
429+
logger.warn(
430+
`Deep link: worktrees not available after ${MAX_DEEP_LINK_RETRIES} retries, ` +
431+
`opening feature ${initialFeatureId} without switching worktree`
432+
);
345433
}
346434

347-
// Switch to the correct worktree based on the feature's branchName
348-
if (feature.branchName && deepLinkWorktrees.length > 0) {
349-
const targetWorktree = deepLinkWorktrees.find((w) => w.branch === feature.branchName);
435+
// Switch to the correct worktree based on the feature's branchName.
436+
// IMPORTANT: Wrap in startTransition to batch the Zustand store update with
437+
// any concurrent React state updates. Without this, the synchronous store
438+
// mutation cascades through useAutoMode → refreshStatus → setAutoModeRunning,
439+
// which can trigger React error #185 on mobile Safari/PWA crash loops.
440+
if (feature.branchName && resolvedWorktrees.length > 0) {
441+
const targetWorktree = resolvedWorktrees.find((w) => w.branch === feature.branchName);
350442
if (targetWorktree) {
351443
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
352444
const isAlreadySelected = targetWorktree.isMain
@@ -356,23 +448,27 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
356448
logger.info(
357449
`Deep link: switching to worktree "${targetWorktree.branch}" for feature ${initialFeatureId}`
358450
);
359-
setCurrentWorktree(
360-
currentProject.path,
361-
targetWorktree.isMain ? null : targetWorktree.path,
362-
targetWorktree.branch
363-
);
451+
startTransition(() => {
452+
setCurrentWorktree(
453+
currentProject.path,
454+
targetWorktree.isMain ? null : targetWorktree.path,
455+
targetWorktree.branch
456+
);
457+
});
364458
}
365459
}
366-
} else if (!feature.branchName && deepLinkWorktrees.length > 0) {
460+
} else if (!feature.branchName && resolvedWorktrees.length > 0) {
367461
// Feature has no branch - should be on the main worktree
368462
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
369463
if (currentWt?.path !== null && currentWt !== null) {
370-
const mainWorktree = deepLinkWorktrees.find((w) => w.isMain);
464+
const mainWorktree = resolvedWorktrees.find((w) => w.isMain);
371465
if (mainWorktree) {
372466
logger.info(
373467
`Deep link: switching to main worktree for unassigned feature ${initialFeatureId}`
374468
);
375-
setCurrentWorktree(currentProject.path, null, mainWorktree.branch);
469+
startTransition(() => {
470+
setCurrentWorktree(currentProject.path, null, mainWorktree.branch);
471+
});
376472
}
377473
}
378474
}
@@ -387,6 +483,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
387483
hookFeatures,
388484
currentProject?.path,
389485
deepLinkWorktrees,
486+
queryClient,
390487
setCurrentWorktree,
391488
setOutputFeature,
392489
setShowOutputModal,
@@ -764,11 +861,15 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
764861

765862
// Recovery handler for BoardErrorBoundary: reset worktree selection to main
766863
// so the board can re-render without the stale worktree state that caused the crash.
864+
// Wrapped in startTransition to batch with concurrent React updates and avoid
865+
// triggering another cascade during recovery.
767866
const handleBoardRecover = useCallback(() => {
768867
if (!currentProject) return;
769868
const mainWorktree = worktrees.find((w) => w.isMain);
770869
const mainBranch = mainWorktree?.branch || 'main';
771-
setCurrentWorktree(currentProject.path, null, mainBranch);
870+
startTransition(() => {
871+
setCurrentWorktree(currentProject.path, null, mainBranch);
872+
});
772873
}, [currentProject, worktrees, setCurrentWorktree]);
773874

774875
// Helper function to add and select a worktree

0 commit comments

Comments
 (0)