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>
This commit is contained in:
gsxdsm
2026-03-04 10:13:24 -08:00
committed by GitHub
parent 20e7c74b17
commit 26b73df097
8 changed files with 297 additions and 41 deletions

View File

@@ -595,12 +595,12 @@ export class EventHookService {
if (clickUrl && context.projectPath) { if (clickUrl && context.projectPath) {
try { try {
const url = new URL(clickUrl); const url = new URL(clickUrl);
url.pathname = '/board';
// Add projectPath so the UI can switch to the correct project
url.searchParams.set('projectPath', context.projectPath);
// Add featureId as query param for deep linking to board with feature output modal // Add featureId as query param for deep linking to board with feature output modal
if (context.featureId) { if (context.featureId) {
url.pathname = '/board';
url.searchParams.set('featureId', context.featureId); url.searchParams.set('featureId', context.featureId);
} else {
url.pathname = '/board';
} }
clickUrl = url.toString(); clickUrl = url.toString();
} catch (error) { } catch (error) {

View File

@@ -68,7 +68,13 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
// Navigate to the relevant view based on notification type // Navigate to the relevant view based on notification type
if (notification.featureId) { if (notification.featureId) {
navigate({ to: '/board', search: { featureId: notification.featureId } }); navigate({
to: '/board',
search: {
featureId: notification.featureId,
projectPath: notification.projectPath || undefined,
},
});
} }
}, },
[handleMarkAsRead, setPopoverOpen, navigate] [handleMarkAsRead, setPopoverOpen, navigate]

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef, startTransition } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import type { PointerEvent as ReactPointerEvent } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react';
import { import {
@@ -37,6 +37,7 @@ import type {
ReasoningEffort, ReasoningEffort,
} from '@automaker/types'; } from '@automaker/types';
import { pathsEqual } from '@/lib/utils'; import { pathsEqual } from '@/lib/utils';
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
BoardBackgroundModal, BoardBackgroundModal,
@@ -117,9 +118,11 @@ const logger = createLogger('Board');
interface BoardViewProps { interface BoardViewProps {
/** Feature ID from URL parameter - if provided, opens output modal for this feature on load */ /** Feature ID from URL parameter - if provided, opens output modal for this feature on load */
initialFeatureId?: string; initialFeatureId?: string;
/** Project path from URL parameter - if provided, switches to this project before handling deep link */
initialProjectPath?: string;
} }
export function BoardView({ initialFeatureId }: BoardViewProps) { export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewProps) {
const { const {
currentProject, currentProject,
defaultSkipTests, defaultSkipTests,
@@ -139,6 +142,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
setPipelineConfig, setPipelineConfig,
featureTemplates, featureTemplates,
defaultSortNewestCardOnTop, defaultSortNewestCardOnTop,
upsertAndSetCurrentProject,
} = useAppStore( } = useAppStore(
useShallow((state) => ({ useShallow((state) => ({
currentProject: state.currentProject, currentProject: state.currentProject,
@@ -159,6 +163,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
setPipelineConfig: state.setPipelineConfig, setPipelineConfig: state.setPipelineConfig,
featureTemplates: state.featureTemplates, featureTemplates: state.featureTemplates,
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop, defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop,
upsertAndSetCurrentProject: state.upsertAndSetCurrentProject,
})) }))
); );
// Also get keyboard shortcuts for the add feature shortcut // Also get keyboard shortcuts for the add feature shortcut
@@ -305,6 +310,53 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
setFeaturesWithContext, setFeaturesWithContext,
}); });
// Handle deep link project switching - if URL includes a projectPath that differs from
// the current project, switch to the target project first. The feature/worktree deep link
// effect below will fire naturally once the project switch triggers a features reload.
const handledProjectPathRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!initialProjectPath || handledProjectPathRef.current === initialProjectPath) {
return;
}
// Check if we're already on the correct project
if (currentProject?.path && pathsEqual(currentProject.path, initialProjectPath)) {
handledProjectPathRef.current = initialProjectPath;
return;
}
handledProjectPathRef.current = initialProjectPath;
const switchProject = async () => {
try {
const initResult = await initializeProject(initialProjectPath);
if (!initResult.success) {
logger.warn(
`Deep link: failed to initialize project "${initialProjectPath}":`,
initResult.error
);
toast.error('Failed to open project from link', {
description: initResult.error || 'Unknown error',
});
return;
}
// Derive project name from path basename
const projectName =
initialProjectPath.split(/[/\\]/).filter(Boolean).pop() || initialProjectPath;
logger.info(`Deep link: switching to project "${projectName}" at ${initialProjectPath}`);
upsertAndSetCurrentProject(initialProjectPath, projectName);
} catch (error) {
logger.error('Deep link: project switch failed:', error);
toast.error('Failed to switch project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
};
switchProject();
}, [initialProjectPath, currentProject?.path, upsertAndSetCurrentProject]);
// Handle initial feature ID from URL - switch to the correct worktree and open output modal // Handle initial feature ID from URL - switch to the correct worktree and open output modal
// Uses a ref to track which featureId has been handled to prevent re-opening // Uses a ref to track which featureId has been handled to prevent re-opening
// when the component re-renders but initialFeatureId hasn't changed. // when the component re-renders but initialFeatureId hasn't changed.
@@ -325,6 +377,17 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
[currentProject?.path] [currentProject?.path]
) )
); );
// Track how many render cycles we've waited for worktrees during a deep link.
// If the Zustand store never gets populated (e.g., WorktreePanel hasn't mounted,
// useWorktrees setting is off, or the worktree query failed), we stop waiting
// after a threshold and open the modal without switching worktree.
const deepLinkRetryCountRef = useRef(0);
// Reset retry count when the feature ID changes
useEffect(() => {
deepLinkRetryCountRef.current = 0;
}, [initialFeatureId]);
useEffect(() => { useEffect(() => {
if ( if (
!initialFeatureId || !initialFeatureId ||
@@ -339,14 +402,43 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
const feature = hookFeatures.find((f) => f.id === initialFeatureId); const feature = hookFeatures.find((f) => f.id === initialFeatureId);
if (!feature) return; if (!feature) return;
// If the feature has a branch, wait for worktrees to load so we can switch // Resolve worktrees: prefer the Zustand store (reactive), but fall back to
if (feature.branchName && deepLinkWorktrees.length === 0) { // the React Query cache if the store hasn't been populated yet. The store is
return; // Worktrees not loaded yet - effect will re-run when they load // only synced by the WorktreePanel's useWorktrees hook, which may not have
// rendered yet during a deep link cold start. Reading the query cache directly
// avoids an indefinite wait that hangs the app on the loading screen.
let resolvedWorktrees = deepLinkWorktrees;
if (resolvedWorktrees.length === 0 && currentProject.path) {
const cachedData = queryClient.getQueryData(queryKeys.worktrees.all(currentProject.path)) as
| { worktrees?: WorktreeInfo[] }
| undefined;
if (cachedData?.worktrees && cachedData.worktrees.length > 0) {
resolvedWorktrees = cachedData.worktrees as typeof deepLinkWorktrees;
}
} }
// Switch to the correct worktree based on the feature's branchName // If the feature has a branch and worktrees aren't available yet, wait briefly.
if (feature.branchName && deepLinkWorktrees.length > 0) { // After enough retries, proceed without switching worktree to avoid hanging.
const targetWorktree = deepLinkWorktrees.find((w) => w.branch === feature.branchName); const MAX_DEEP_LINK_RETRIES = 10;
if (feature.branchName && resolvedWorktrees.length === 0) {
deepLinkRetryCountRef.current++;
if (deepLinkRetryCountRef.current < MAX_DEEP_LINK_RETRIES) {
return; // Worktrees not loaded yet - effect will re-run when they load
}
// Exceeded retry limit — proceed without worktree switch to avoid hanging
logger.warn(
`Deep link: worktrees not available after ${MAX_DEEP_LINK_RETRIES} retries, ` +
`opening feature ${initialFeatureId} without switching worktree`
);
}
// Switch to the correct worktree based on the feature's branchName.
// IMPORTANT: Wrap in startTransition to batch the Zustand store update with
// any concurrent React state updates. Without this, the synchronous store
// mutation cascades through useAutoMode → refreshStatus → setAutoModeRunning,
// which can trigger React error #185 on mobile Safari/PWA crash loops.
if (feature.branchName && resolvedWorktrees.length > 0) {
const targetWorktree = resolvedWorktrees.find((w) => w.branch === feature.branchName);
if (targetWorktree) { if (targetWorktree) {
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path); const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
const isAlreadySelected = targetWorktree.isMain const isAlreadySelected = targetWorktree.isMain
@@ -356,23 +448,27 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
logger.info( logger.info(
`Deep link: switching to worktree "${targetWorktree.branch}" for feature ${initialFeatureId}` `Deep link: switching to worktree "${targetWorktree.branch}" for feature ${initialFeatureId}`
); );
setCurrentWorktree( startTransition(() => {
currentProject.path, setCurrentWorktree(
targetWorktree.isMain ? null : targetWorktree.path, currentProject.path,
targetWorktree.branch targetWorktree.isMain ? null : targetWorktree.path,
); targetWorktree.branch
);
});
} }
} }
} else if (!feature.branchName && deepLinkWorktrees.length > 0) { } else if (!feature.branchName && resolvedWorktrees.length > 0) {
// Feature has no branch - should be on the main worktree // Feature has no branch - should be on the main worktree
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path); const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
if (currentWt?.path !== null && currentWt !== null) { if (currentWt?.path !== null && currentWt !== null) {
const mainWorktree = deepLinkWorktrees.find((w) => w.isMain); const mainWorktree = resolvedWorktrees.find((w) => w.isMain);
if (mainWorktree) { if (mainWorktree) {
logger.info( logger.info(
`Deep link: switching to main worktree for unassigned feature ${initialFeatureId}` `Deep link: switching to main worktree for unassigned feature ${initialFeatureId}`
); );
setCurrentWorktree(currentProject.path, null, mainWorktree.branch); startTransition(() => {
setCurrentWorktree(currentProject.path, null, mainWorktree.branch);
});
} }
} }
} }
@@ -387,6 +483,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
hookFeatures, hookFeatures,
currentProject?.path, currentProject?.path,
deepLinkWorktrees, deepLinkWorktrees,
queryClient,
setCurrentWorktree, setCurrentWorktree,
setOutputFeature, setOutputFeature,
setShowOutputModal, setShowOutputModal,
@@ -764,11 +861,15 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
// Recovery handler for BoardErrorBoundary: reset worktree selection to main // Recovery handler for BoardErrorBoundary: reset worktree selection to main
// so the board can re-render without the stale worktree state that caused the crash. // so the board can re-render without the stale worktree state that caused the crash.
// Wrapped in startTransition to batch with concurrent React updates and avoid
// triggering another cascade during recovery.
const handleBoardRecover = useCallback(() => { const handleBoardRecover = useCallback(() => {
if (!currentProject) return; if (!currentProject) return;
const mainWorktree = worktrees.find((w) => w.isMain); const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || 'main'; const mainBranch = mainWorktree?.branch || 'main';
setCurrentWorktree(currentProject.path, null, mainBranch); startTransition(() => {
setCurrentWorktree(currentProject.path, null, mainBranch);
});
}, [currentProject, worktrees, setCurrentWorktree]); }, [currentProject, worktrees, setCurrentWorktree]);
// Helper function to add and select a worktree // Helper function to add and select a worktree

View File

@@ -14,6 +14,63 @@ import { useFeature, useAgentOutput } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys'; import { queryKeys } from '@/lib/query-keys';
import { getFirstNonEmptySummary } from '@/lib/summary-selection'; import { getFirstNonEmptySummary } from '@/lib/summary-selection';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { isMobileDevice } from '@/lib/mobile-detect';
// Global concurrency control for mobile mount staggering.
// When many AgentInfoPanel instances mount simultaneously (e.g., worktree switch
// with 50+ cards), we spread queries over a wider window and cap how many
// panels can be querying concurrently to prevent mobile Safari crashes.
//
// The mechanism works in two layers:
// 1. Random delay (0-6s) - spreads mount times so not all panels try to query at once
// 2. Concurrency slots (max 4) - even after the delay, only N panels can query simultaneously
//
// Instance tracking ensures the queue resets if all panels unmount (e.g., navigation).
const MOBILE_MAX_CONCURRENT_QUERIES = 4;
const MOBILE_STAGGER_WINDOW_MS = 6000; // 6s window (vs previous 2s)
let activeMobileQueryCount = 0;
let pendingMobileQueue: Array<() => void> = [];
let mountedPanelCount = 0;
function acquireMobileQuerySlot(): Promise<void> {
if (!isMobileDevice) return Promise.resolve();
if (activeMobileQueryCount < MOBILE_MAX_CONCURRENT_QUERIES) {
activeMobileQueryCount++;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
pendingMobileQueue.push(() => {
activeMobileQueryCount++;
resolve();
});
});
}
function releaseMobileQuerySlot(): void {
if (!isMobileDevice) return;
activeMobileQueryCount = Math.max(0, activeMobileQueryCount - 1);
const next = pendingMobileQueue.shift();
if (next) next();
}
function trackPanelMount(): void {
if (!isMobileDevice) return;
mountedPanelCount++;
}
function trackPanelUnmount(): void {
if (!isMobileDevice) return;
mountedPanelCount = Math.max(0, mountedPanelCount - 1);
// If all panels unmounted (e.g., navigated away from board or worktree switch),
// reset the queue to prevent stale state from blocking future mounts.
if (mountedPanelCount === 0) {
activeMobileQueryCount = 0;
// Drain any pending callbacks so their Promises resolve (components already unmounted)
const pending = pendingMobileQueue;
pendingMobileQueue = [];
for (const cb of pending) cb();
}
}
/** /**
* Formats thinking level for compact display * Formats thinking level for compact display
@@ -66,6 +123,12 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false); const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track mounted panel count for global queue reset on full unmount
useEffect(() => {
trackPanelMount();
return () => trackPanelUnmount();
}, []);
// Get providers from store for provider-aware model name display // Get providers from store for provider-aware model name display
// This allows formatModelName to show provider-specific model names (e.g., "GLM 4.7" instead of "Sonnet 4.5") // This allows formatModelName to show provider-specific model names (e.g., "GLM 4.7" instead of "Sonnet 4.5")
// when a feature was executed using a Claude-compatible provider // when a feature was executed using a Claude-compatible provider
@@ -92,6 +155,41 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Determine if we should poll for updates // Determine if we should poll for updates
const shouldFetchData = feature.status !== 'backlog' && feature.status !== 'merge_conflict'; const shouldFetchData = feature.status !== 'backlog' && feature.status !== 'merge_conflict';
// On mobile, stagger initial per-card queries to prevent a mount storm.
// When a worktree loads with many cards, all AgentInfoPanel instances mount
// simultaneously. Without staggering, each card fires useFeature + useAgentOutput
// queries at the same time, creating 60-100+ concurrent API calls that crash
// mobile Safari. Actively running cards fetch immediately (priority data);
// other cards defer by a random delay AND wait for a concurrency slot.
// The stagger window is 6s (vs previous 2s) to spread load for worktrees
// with 50+ features. The concurrency limiter caps active queries to 4 at a time,
// preventing the burst that overwhelms mobile Safari's connection handling.
const [mountReady, setMountReady] = useState(!isMobileDevice || !!isActivelyRunning);
useEffect(() => {
if (mountReady) return;
let cancelled = false;
const delay = Math.random() * MOBILE_STAGGER_WINDOW_MS;
const timer = setTimeout(() => {
// After the random delay, also wait for a concurrency slot
acquireMobileQuerySlot().then(() => {
if (!cancelled) {
setMountReady(true);
// Release the slot after a brief window to let the initial queries fire
// and return, preventing all slots from being held indefinitely
setTimeout(releaseMobileQuerySlot, 3000);
} else {
releaseMobileQuerySlot();
}
});
}, delay);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [mountReady]);
const queryEnabled = shouldFetchData && mountReady;
// Track whether we're receiving WebSocket events (within threshold) // Track whether we're receiving WebSocket events (within threshold)
// Use a state to trigger re-renders when the WebSocket connection becomes stale // Use a state to trigger re-renders when the WebSocket connection becomes stale
const [isReceivingWsEvents, setIsReceivingWsEvents] = useState(false); const [isReceivingWsEvents, setIsReceivingWsEvents] = useState(false);
@@ -142,34 +240,72 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Fetch fresh feature data for planSpec (store data can be stale for task progress) // Fetch fresh feature data for planSpec (store data can be stale for task progress)
const { data: freshFeature } = useFeature(projectPath, feature.id, { const { data: freshFeature } = useFeature(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent, enabled: queryEnabled && !contextContent,
pollingInterval, pollingInterval,
}); });
// Fetch agent output for parsing // Fetch agent output for parsing
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, { const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent, enabled: queryEnabled && !contextContent,
pollingInterval, pollingInterval,
}); });
// On mount, ensure feature and agent output queries are fresh. // On mount, ensure feature and agent output queries are fresh.
// This handles the worktree switch scenario where cards unmount when filtered out // This handles the worktree switch scenario where cards unmount when filtered out
// and remount when the user switches back. Without this, the React Query cache // and remount when the user switches back. Without this, the React Query cache
// may serve stale data (or no data) for the individual feature query, causing // may serve stale data for the individual feature query, causing the todo list
// the todo list to appear empty until the next polling cycle. // to appear empty until the next polling cycle.
//
// IMPORTANT: Only invalidate if the cached data EXISTS and is STALE.
// During worktree switches, ALL cards in the new worktree remount simultaneously.
// If every card fires invalidateQueries(), it creates a query storm (40-100+
// concurrent invalidations) that overwhelms React's rendering pipeline on mobile
// Safari/PWA, causing crashes. The key insight: if a query has NEVER been fetched
// (no dataUpdatedAt), there's nothing stale to invalidate — the useFeature/
// useAgentOutput hooks will fetch fresh data when their `enabled` flag is true.
// We only need to invalidate when cached data exists but is outdated.
//
// On mobile, skip mount-time invalidation entirely. The staggered useFeature/
// useAgentOutput queries already fetch fresh data — invalidation is redundant
// and creates the exact query storm we're trying to prevent. The stale threshold
// is also higher on mobile (30s vs 10s) to further reduce unnecessary refetches
// during the settling period after a worktree switch.
useEffect(() => { useEffect(() => {
if (shouldFetchData && projectPath && feature.id && !contextContent) { if (queryEnabled && projectPath && feature.id && !contextContent) {
// Invalidate both the single feature and agent output queries to trigger immediate refetch // On mobile, skip mount-time invalidation — the useFeature/useAgentOutput
queryClient.invalidateQueries({ // hooks will handle the initial fetch after the stagger delay.
queryKey: queryKeys.features.single(projectPath, feature.id), if (isMobileDevice) return;
});
queryClient.invalidateQueries({ const MOUNT_STALE_THRESHOLD = 10_000; // 10s — skip invalidation if data is fresh
queryKey: queryKeys.features.agentOutput(projectPath, feature.id), const now = Date.now();
});
const featureQuery = queryClient.getQueryState(
queryKeys.features.single(projectPath, feature.id)
);
const agentOutputQuery = queryClient.getQueryState(
queryKeys.features.agentOutput(projectPath, feature.id)
);
// Only invalidate queries that have cached data AND are stale.
// Skip if the query has never been fetched (dataUpdatedAt is undefined) —
// the useFeature/useAgentOutput hooks will handle the initial fetch.
if (featureQuery?.dataUpdatedAt && now - featureQuery.dataUpdatedAt > MOUNT_STALE_THRESHOLD) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.single(projectPath, feature.id),
});
}
if (
agentOutputQuery?.dataUpdatedAt &&
now - agentOutputQuery.dataUpdatedAt > MOUNT_STALE_THRESHOLD
) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.agentOutput(projectPath, feature.id),
});
}
} }
// Only run on mount (feature.id and projectPath identify this specific card instance) // Runs when mount staggering completes (queryEnabled becomes true) or on initial mount
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [feature.id, projectPath]); }, [queryEnabled, feature.id, projectPath]);
// Parse agent output into agentInfo // Parse agent output into agentInfo
const agentInfo = useMemo(() => { const agentInfo = useMemo(() => {

View File

@@ -94,8 +94,14 @@ export function NotificationsView() {
// Navigate to the relevant view based on notification type // Navigate to the relevant view based on notification type
if (notification.featureId) { if (notification.featureId) {
// Navigate to board view with feature ID to show output // Navigate to board view with feature ID and project path to show output
navigate({ to: '/board', search: { featureId: notification.featureId } }); navigate({
to: '/board',
search: {
featureId: notification.featureId,
projectPath: notification.projectPath || undefined,
},
});
} }
}, },
[handleMarkAsRead, navigate] [handleMarkAsRead, navigate]

View File

@@ -136,8 +136,14 @@ export function RecentActivityFeed({ activities, maxItems = 10 }: RecentActivity
upsertAndSetCurrentProject(projectPath, projectName); upsertAndSetCurrentProject(projectPath, projectName);
if (activity.featureId) { if (activity.featureId) {
// Navigate to the specific feature // Navigate to the specific feature with project path for deep link handling
navigate({ to: '/board', search: { featureId: activity.featureId } }); navigate({
to: '/board',
search: {
featureId: activity.featureId,
projectPath: projectPath || undefined,
},
});
} else { } else {
navigate({ to: '/board' }); navigate({ to: '/board' });
} }

View File

@@ -6,6 +6,6 @@ export const Route = createLazyFileRoute('/board')({
}); });
function BoardRouteComponent() { function BoardRouteComponent() {
const { featureId } = useSearch({ from: '/board' }); const { featureId, projectPath } = useSearch({ from: '/board' });
return <BoardView initialFeatureId={featureId} />; return <BoardView initialFeatureId={featureId} initialProjectPath={projectPath} />;
} }

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
// Search params schema for board route // Search params schema for board route
const boardSearchSchema = z.object({ const boardSearchSchema = z.object({
featureId: z.string().optional(), featureId: z.string().optional(),
projectPath: z.string().optional(),
}); });
// Component is lazy-loaded via board.lazy.tsx for code splitting. // Component is lazy-loaded via board.lazy.tsx for code splitting.