1- import { useEffect , useState , useCallback , useMemo , useRef } from 'react' ;
1+ import { useEffect , useState , useCallback , useMemo , useRef , startTransition } from 'react' ;
22import { createLogger } from '@automaker/utils/logger' ;
33import type { PointerEvent as ReactPointerEvent } from 'react' ;
44import {
@@ -37,6 +37,7 @@ import type {
3737 ReasoningEffort ,
3838} from '@automaker/types' ;
3939import { pathsEqual } from '@/lib/utils' ;
40+ import { initializeProject } from '@/lib/project-init' ;
4041import { toast } from 'sonner' ;
4142import {
4243 BoardBackgroundModal ,
@@ -117,9 +118,11 @@ const logger = createLogger('Board');
117118interface 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