mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user