5 Commits

Author SHA1 Message Date
gsxdsm
7be8163b84 Preserve pipeline work by using waiting_approval on post-completion errors (#836)
* Changes from fix/pipeline-not-finishing

* fix: Prevent overwriting merge_conflict status in pipeline error handlers

* fix: Handle feature loading failures gracefully in error recovery

* ```
fix: Add logging when feature reload fails during status update
```
2026-03-04 20:22:44 -08:00
gsxdsm
26b73df097 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>
2026-03-04 10:13:24 -08:00
gsxdsm
20e7c74b17 Fix event endpoint persistence (#831)
* Changes from fix/event-hook-endpoint

* fix: Allow empty eventHooks/ntfyEndpoints to reconcile from server

Remove the `length > 0` guards in fast-hydrate reconciliation that
prevented intentional empty-array clears from syncing across clients.
Server-side wipe protection (`__allowEmpty*` escape hatches) already
ensures empty arrays in the server are intentional.

Addresses PR #831 review feedback from CodeRabbit and Gemini.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:33:12 -08:00
gsxdsm
dd7108a7a0 Fixes critical React crash on the Kanban board view (#830)
* Changes from fix/board-react-crash

* fix: Prevent cascading re-renders and crashes from high-frequency WS events
2026-03-03 19:23:44 -08:00
gsxdsm
ae48065820 Fix dev server hang by reducing log spam and event frequency (#828)
* Changes from fix/dev-server-hang

* fix: Address PR #828 review feedback

- Reset RAF buffer on context changes (worktree switch, dev-server restart)
  to prevent stale output from flushing into new sessions
- Fix high-frequency WebSocket filter to catch auto-mode:event wrapping
  (auto_mode_progress is wrapped in auto-mode:event) and add feature:progress
- Reorder Vite aliases so explicit jsx-runtime entries aren't shadowed by
  the broad /^react(\/|$)/ regex (Vite uses first-match-wins)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: Batch dev server logs and fix React module resolution order

* feat: Add fallback timer for flushing dev server logs in background tabs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:52:44 -08:00
23 changed files with 662 additions and 202 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

@@ -179,6 +179,7 @@ ${feature.spec}
const abortController = tempRunningFeature.abortController; const abortController = tempRunningFeature.abortController;
if (isAutoMode) await this.saveExecutionStateFn(projectPath); if (isAutoMode) await this.saveExecutionStateFn(projectPath);
let feature: Feature | null = null; let feature: Feature | null = null;
let pipelineCompleted = false;
try { try {
validateWorkingDirectory(projectPath); validateWorkingDirectory(projectPath);
@@ -431,6 +432,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
testAttempts: 0, testAttempts: 0,
maxTestAttempts: 5, maxTestAttempts: 5,
}); });
pipelineCompleted = true;
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it // Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
const refreshed = await this.loadFeatureFn(projectPath, featureId); const refreshed = await this.loadFeatureFn(projectPath, featureId);
if (refreshed?.status === 'merge_conflict') { if (refreshed?.status === 'merge_conflict') {
@@ -541,7 +543,30 @@ Please continue from where you left off and complete all remaining tasks. Use th
} }
} else { } else {
logger.error(`Feature ${featureId} failed:`, error); logger.error(`Feature ${featureId} failed:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog'); // If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[executeFeature] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
let currentStatus: string | undefined;
try {
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
currentStatus = currentFeature?.status;
} catch (loadErr) {
// If loading fails, log it and proceed with the status update anyway
logger.warn(
`[executeFeature] Failed to reload feature ${featureId} for status check:`,
loadErr
);
}
if (currentStatus !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
this.eventBus.emitAutoModeEvent('auto_mode_error', { this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId, featureId,
featureName: feature?.title, featureName: feature?.title,

View File

@@ -350,6 +350,7 @@ export class PipelineOrchestrator {
}); });
const abortController = runningEntry.abortController; const abortController = runningEntry.abortController;
runningEntry.branchName = feature.branchName ?? null; runningEntry.branchName = feature.branchName ?? null;
let pipelineCompleted = false;
try { try {
validateWorkingDirectory(projectPath); validateWorkingDirectory(projectPath);
@@ -403,6 +404,7 @@ export class PipelineOrchestrator {
}; };
await this.executePipeline(context); await this.executePipeline(context);
pipelineCompleted = true;
// Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict) // Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict)
const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId); const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
@@ -439,8 +441,21 @@ export class PipelineOrchestrator {
}); });
} }
} else { } else {
// If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[resumeFromStep] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
logger.error(`Pipeline resume failed for ${featureId}:`, error); logger.error(`Pipeline resume failed for ${featureId}:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog'); // Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
const currentFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
if (currentFeature?.status !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
this.eventBus.emitAutoModeEvent('auto_mode_error', { this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,

View File

@@ -2000,5 +2000,60 @@ describe('execution-service.ts', () => {
// The only non-in_progress status call should be absent since merge_conflict returns early // The only non-in_progress status call should be absent since merge_conflict returns early
expect(statusCalls.length).toBe(0); expect(statusCalls.length).toBe(0);
}); });
it('sets waiting_approval instead of backlog when error occurs after pipeline completes', async () => {
// Set up pipeline with steps
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
version: 1,
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
});
// Pipeline succeeds, but reading agent output throws after pipeline completes
mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined);
// Simulate an error after pipeline completes by making loadFeature throw
// on the post-pipeline refresh call
let loadCallCount = 0;
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
loadCallCount++;
if (loadCallCount === 1) return testFeature; // initial load
// Second call is the task-retry check, third is the pipeline refresh
if (loadCallCount <= 2) return testFeature;
throw new Error('Unexpected post-pipeline error');
});
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
// Should set to waiting_approval, NOT backlog, since pipeline completed
const backlogCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'backlog');
expect(backlogCalls.length).toBe(0);
const waitingCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'waiting_approval');
expect(waitingCalls.length).toBeGreaterThan(0);
});
it('still sets backlog when error occurs before pipeline completes', async () => {
// Set up pipeline with steps
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
version: 1,
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
});
// Pipeline itself throws (e.g., agent error during pipeline step)
mockExecutePipelineFn = vi.fn().mockRejectedValue(new Error('Agent execution failed'));
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
// Should still set to backlog since pipeline did NOT complete
const backlogCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'backlog');
expect(backlogCalls.length).toBe(1);
});
}); });
}); });

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

@@ -1,6 +1,5 @@
// @ts-nocheck - feature update logic with partial updates and image/file handling // @ts-nocheck - feature update logic with partial updates and image/file handling
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { import {
Feature, Feature,
FeatureImage, FeatureImage,
@@ -18,7 +17,10 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils'; import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { queryKeys } from '@/lib/query-keys'; import {
markFeatureTransitioning,
unmarkFeatureTransitioning,
} from '@/lib/feature-transition-state';
const logger = createLogger('BoardActions'); const logger = createLogger('BoardActions');
@@ -116,8 +118,6 @@ export function useBoardActions({
currentWorktreeBranch, currentWorktreeBranch,
stopFeature, stopFeature,
}: UseBoardActionsProps) { }: UseBoardActionsProps) {
const queryClient = useQueryClient();
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent // IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
// subscribing to the entire store. Bare useAppStore() causes the host component // subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects // (BoardView) to re-render on EVERY store change, which cascades through effects
@@ -125,7 +125,6 @@ export function useBoardActions({
const addFeature = useAppStore((s) => s.addFeature); const addFeature = useAppStore((s) => s.addFeature);
const updateFeature = useAppStore((s) => s.updateFeature); const updateFeature = useAppStore((s) => s.updateFeature);
const removeFeature = useAppStore((s) => s.removeFeature); const removeFeature = useAppStore((s) => s.removeFeature);
const moveFeature = useAppStore((s) => s.moveFeature);
const worktreesEnabled = useAppStore((s) => s.useWorktrees); const worktreesEnabled = useAppStore((s) => s.useWorktrees);
const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking); const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking);
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode); const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
@@ -707,8 +706,7 @@ export function useBoardActions({
try { try {
const result = await verifyFeatureMutation.mutateAsync(feature.id); const result = await verifyFeatureMutation.mutateAsync(feature.id);
if (result.passes) { if (result.passes) {
// Immediately move card to verified column (optimistic update) // persistFeatureUpdate handles the optimistic RQ cache update internally
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, { persistFeatureUpdate(feature.id, {
status: 'verified', status: 'verified',
justFinishedAt: undefined, justFinishedAt: undefined,
@@ -725,7 +723,7 @@ export function useBoardActions({
// Error toast is already shown by the mutation's onError handler // Error toast is already shown by the mutation's onError handler
} }
}, },
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate] [currentProject, verifyFeatureMutation, persistFeatureUpdate]
); );
const handleResumeFeature = useCallback( const handleResumeFeature = useCallback(
@@ -742,7 +740,6 @@ export function useBoardActions({
const handleManualVerify = useCallback( const handleManualVerify = useCallback(
(feature: Feature) => { (feature: Feature) => {
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, { persistFeatureUpdate(feature.id, {
status: 'verified', status: 'verified',
justFinishedAt: undefined, justFinishedAt: undefined,
@@ -751,7 +748,7 @@ export function useBoardActions({
description: `Marked as verified: ${truncateDescription(feature.description)}`, description: `Marked as verified: ${truncateDescription(feature.description)}`,
}); });
}, },
[moveFeature, persistFeatureUpdate] [persistFeatureUpdate]
); );
const handleMoveBackToInProgress = useCallback( const handleMoveBackToInProgress = useCallback(
@@ -760,13 +757,12 @@ export function useBoardActions({
status: 'in_progress' as const, status: 'in_progress' as const,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
}; };
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates); persistFeatureUpdate(feature.id, updates);
toast.info('Feature moved back', { toast.info('Feature moved back', {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`, description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
}); });
}, },
[updateFeature, persistFeatureUpdate] [persistFeatureUpdate]
); );
const handleOpenFollowUp = useCallback( const handleOpenFollowUp = useCallback(
@@ -885,7 +881,6 @@ export function useBoardActions({
); );
if (result.success) { if (result.success) {
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, { status: 'verified' }); persistFeatureUpdate(feature.id, { status: 'verified' });
toast.success('Feature committed', { toast.success('Feature committed', {
description: `Committed and verified: ${truncateDescription(feature.description)}`, description: `Committed and verified: ${truncateDescription(feature.description)}`,
@@ -907,7 +902,7 @@ export function useBoardActions({
await loadFeatures(); await loadFeatures();
} }
}, },
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated] [currentProject, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
); );
const handleMergeFeature = useCallback( const handleMergeFeature = useCallback(
@@ -951,17 +946,12 @@ export function useBoardActions({
const handleCompleteFeature = useCallback( const handleCompleteFeature = useCallback(
(feature: Feature) => { (feature: Feature) => {
const updates = { persistFeatureUpdate(feature.id, { status: 'completed' as const });
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success('Feature completed', { toast.success('Feature completed', {
description: `Archived: ${truncateDescription(feature.description)}`, description: `Archived: ${truncateDescription(feature.description)}`,
}); });
}, },
[updateFeature, persistFeatureUpdate] [persistFeatureUpdate]
); );
const handleUnarchiveFeature = useCallback( const handleUnarchiveFeature = useCallback(
@@ -978,11 +968,7 @@ export function useBoardActions({
(projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true) (projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true)
: featureBranch === currentWorktreeBranch; : featureBranch === currentWorktreeBranch;
const updates: Partial<Feature> = { persistFeatureUpdate(feature.id, { status: 'verified' as const });
status: 'verified' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
if (willBeVisibleOnCurrentView) { if (willBeVisibleOnCurrentView) {
toast.success('Feature restored', { toast.success('Feature restored', {
@@ -994,13 +980,7 @@ export function useBoardActions({
}); });
} }
}, },
[ [persistFeatureUpdate, currentWorktreeBranch, projectPath, isPrimaryWorktreeBranch]
updateFeature,
persistFeatureUpdate,
currentWorktreeBranch,
projectPath,
isPrimaryWorktreeBranch,
]
); );
const handleViewOutput = useCallback( const handleViewOutput = useCallback(
@@ -1031,6 +1011,13 @@ export function useBoardActions({
const handleForceStopFeature = useCallback( const handleForceStopFeature = useCallback(
async (feature: Feature) => { async (feature: Feature) => {
// Mark this feature as transitioning so WebSocket-driven query invalidation
// (useAutoModeQueryInvalidation) skips redundant cache invalidations while
// persistFeatureUpdate is handling the optimistic update. Without this guard,
// auto_mode_error / auto_mode_stopped WS events race with the optimistic
// update and cause cache flip-flops that cascade through useBoardColumnFeatures,
// triggering React error #185 on mobile.
markFeatureTransitioning(feature.id);
try { try {
await stopFeature(feature.id); await stopFeature(feature.id);
@@ -1048,25 +1035,11 @@ export function useBoardActions({
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id); removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
} }
// Optimistically update the React Query features cache so the board
// moves the card immediately. Without this, the card stays in
// "in_progress" until the next poll cycle (30s) because the async
// refetch races with the persistFeatureUpdate write.
if (currentProject) {
queryClient.setQueryData(
queryKeys.features.all(currentProject.path),
(oldFeatures: Feature[] | undefined) => {
if (!oldFeatures) return oldFeatures;
return oldFeatures.map((f) =>
f.id === feature.id ? { ...f, status: targetStatus } : f
);
}
);
}
if (targetStatus !== feature.status) { if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus); // persistFeatureUpdate handles the optimistic RQ cache update, the
// Must await to ensure file is written before user can restart // Zustand store update (on server response), and the final cache
// invalidation internally — no need for separate queryClient.setQueryData
// or moveFeature calls which would cause redundant re-renders.
await persistFeatureUpdate(feature.id, { status: targetStatus }); await persistFeatureUpdate(feature.id, { status: targetStatus });
} }
@@ -1083,9 +1056,15 @@ export function useBoardActions({
toast.error('Failed to stop agent', { toast.error('Failed to stop agent', {
description: error instanceof Error ? error.message : 'An error occurred', description: error instanceof Error ? error.message : 'An error occurred',
}); });
} finally {
// Delay unmarking so the refetch triggered by persistFeatureUpdate's
// invalidateQueries() has time to settle before WS-driven invalidations
// are allowed through again. Without this, a WS event arriving during
// the refetch window would trigger a conflicting invalidation.
setTimeout(() => unmarkFeatureTransitioning(feature.id), 500);
} }
}, },
[stopFeature, moveFeature, persistFeatureUpdate, currentProject, queryClient] [stopFeature, persistFeatureUpdate, currentProject]
); );
const handleStartNextFeatures = useCallback(async () => { const handleStartNextFeatures = useCallback(async () => {

View File

@@ -1,5 +1,5 @@
// @ts-nocheck - column filtering logic with dependency resolution and status mapping // @ts-nocheck - column filtering logic with dependency resolution and status mapping
import { useMemo, useCallback, useEffect, useRef } from 'react'; import { useMemo, useCallback, useEffect } from 'react';
import { Feature, useAppStore } from '@/store/app-store'; import { Feature, useAppStore } from '@/store/app-store';
import { import {
createFeatureMap, createFeatureMap,
@@ -177,9 +177,6 @@ export function useBoardColumnFeatures({
(state) => state.clearRecentlyCompletedFeatures (state) => state.clearRecentlyCompletedFeatures
); );
// Track previous feature IDs to detect when features list has been refreshed
const prevFeatureIdsRef = useRef<Set<string>>(new Set());
// Clear recently completed features when the cache refreshes with updated statuses. // Clear recently completed features when the cache refreshes with updated statuses.
// //
// RACE CONDITION SCENARIO THIS PREVENTS: // RACE CONDITION SCENARIO THIS PREVENTS:
@@ -193,12 +190,16 @@ export function useBoardColumnFeatures({
// //
// When the refetch completes with fresh data (status='verified'/'completed'), // When the refetch completes with fresh data (status='verified'/'completed'),
// this effect clears the recentlyCompletedFeatures set since it's no longer needed. // this effect clears the recentlyCompletedFeatures set since it's no longer needed.
// Clear recently completed features when the cache refreshes with updated statuses.
// IMPORTANT: Only depend on `features` (not `recentlyCompletedFeatures`) to avoid a
// re-trigger loop where clearing the set creates a new reference that re-fires this effect.
// Read recentlyCompletedFeatures from the store directly to get the latest value without
// subscribing to it as a dependency.
useEffect(() => { useEffect(() => {
const currentIds = new Set(features.map((f) => f.id)); const currentRecentlyCompleted = useAppStore.getState().recentlyCompletedFeatures;
if (currentRecentlyCompleted.size === 0) return;
// Check if any recently completed features now have terminal statuses in the new data const hasUpdatedStatus = Array.from(currentRecentlyCompleted).some((featureId) => {
// If so, we can clear the tracking since the cache is now fresh
const hasUpdatedStatus = Array.from(recentlyCompletedFeatures).some((featureId) => {
const feature = features.find((f) => f.id === featureId); const feature = features.find((f) => f.id === featureId);
return feature && (feature.status === 'verified' || feature.status === 'completed'); return feature && (feature.status === 'verified' || feature.status === 'completed');
}); });
@@ -206,9 +207,7 @@ export function useBoardColumnFeatures({
if (hasUpdatedStatus) { if (hasUpdatedStatus) {
clearRecentlyCompletedFeatures(); clearRecentlyCompletedFeatures();
} }
}, [features, clearRecentlyCompletedFeatures]);
prevFeatureIdsRef.current = currentIds;
}, [features, recentlyCompletedFeatures, clearRecentlyCompletedFeatures]);
// Memoize column features to prevent unnecessary re-renders // Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => { const columnFeaturesMap = useMemo(() => {

View File

@@ -38,7 +38,6 @@ export function useBoardDragDrop({
// subscribing to the entire store. Bare useAppStore() causes the host component // subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects // (BoardView) to re-render on EVERY store change, which cascades through effects
// and triggers React error #185 (maximum update depth exceeded). // and triggers React error #185 (maximum update depth exceeded).
const moveFeature = useAppStore((s) => s.moveFeature);
const updateFeature = useAppStore((s) => s.updateFeature); const updateFeature = useAppStore((s) => s.updateFeature);
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
@@ -207,7 +206,8 @@ export function useBoardDragDrop({
if (targetStatus === draggedFeature.status) return; if (targetStatus === draggedFeature.status) return;
// Handle different drag scenarios // Handle different drag scenarios
// Note: Worktrees are created server-side at execution time based on feature.branchName // Note: persistFeatureUpdate handles optimistic RQ cache update internally,
// so no separate moveFeature() call is needed.
if (draggedFeature.status === 'backlog' || draggedFeature.status === 'merge_conflict') { if (draggedFeature.status === 'backlog' || draggedFeature.status === 'merge_conflict') {
// From backlog // From backlog
if (targetStatus === 'in_progress') { if (targetStatus === 'in_progress') {
@@ -215,7 +215,6 @@ export function useBoardDragDrop({
// Server will derive workDir from feature.branchName // Server will derive workDir from feature.branchName
await handleStartImplementation(draggedFeature); await handleStartImplementation(draggedFeature);
} else { } else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus }); persistFeatureUpdate(featureId, { status: targetStatus });
} }
} else if (draggedFeature.status === 'waiting_approval') { } else if (draggedFeature.status === 'waiting_approval') {
@@ -223,7 +222,6 @@ export function useBoardDragDrop({
// NOTE: This check must come BEFORE skipTests check because waiting_approval // NOTE: This check must come BEFORE skipTests check because waiting_approval
// features often have skipTests=true, and we want status-based handling first // features often have skipTests=true, and we want status-based handling first
if (targetStatus === 'verified') { if (targetStatus === 'verified') {
moveFeature(featureId, 'verified');
// Clear justFinishedAt timestamp when manually verifying via drag // Clear justFinishedAt timestamp when manually verifying via drag
persistFeatureUpdate(featureId, { persistFeatureUpdate(featureId, {
status: 'verified', status: 'verified',
@@ -237,7 +235,6 @@ export function useBoardDragDrop({
}); });
} else if (targetStatus === 'backlog') { } else if (targetStatus === 'backlog') {
// Allow moving waiting_approval cards back to backlog // Allow moving waiting_approval cards back to backlog
moveFeature(featureId, 'backlog');
// Clear justFinishedAt timestamp when moving back to backlog // Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, { persistFeatureUpdate(featureId, {
status: 'backlog', status: 'backlog',
@@ -269,7 +266,6 @@ export function useBoardDragDrop({
}); });
} }
} }
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' }); persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info( toast.info(
isRunningTask isRunningTask
@@ -291,7 +287,6 @@ export function useBoardDragDrop({
return; return;
} else if (targetStatus === 'verified' && draggedFeature.skipTests) { } else if (targetStatus === 'verified' && draggedFeature.skipTests) {
// Manual verify via drag (only for skipTests features) // Manual verify via drag (only for skipTests features)
moveFeature(featureId, 'verified');
persistFeatureUpdate(featureId, { status: 'verified' }); persistFeatureUpdate(featureId, { status: 'verified' });
toast.success('Feature verified', { toast.success('Feature verified', {
description: `Marked as verified: ${draggedFeature.description.slice( description: `Marked as verified: ${draggedFeature.description.slice(
@@ -304,7 +299,6 @@ export function useBoardDragDrop({
// skipTests feature being moved between verified and waiting_approval // skipTests feature being moved between verified and waiting_approval
if (targetStatus === 'waiting_approval' && draggedFeature.status === 'verified') { if (targetStatus === 'waiting_approval' && draggedFeature.status === 'verified') {
// Move verified feature back to waiting_approval // Move verified feature back to waiting_approval
moveFeature(featureId, 'waiting_approval');
persistFeatureUpdate(featureId, { status: 'waiting_approval' }); persistFeatureUpdate(featureId, { status: 'waiting_approval' });
toast.info('Feature moved back', { toast.info('Feature moved back', {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice( description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
@@ -314,7 +308,6 @@ export function useBoardDragDrop({
}); });
} else if (targetStatus === 'backlog') { } else if (targetStatus === 'backlog') {
// Allow moving skipTests cards back to backlog (from verified) // Allow moving skipTests cards back to backlog (from verified)
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' }); persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', { toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice( description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -327,7 +320,6 @@ export function useBoardDragDrop({
// Handle verified TDD (non-skipTests) features being moved back // Handle verified TDD (non-skipTests) features being moved back
if (targetStatus === 'waiting_approval') { if (targetStatus === 'waiting_approval') {
// Move verified feature back to waiting_approval // Move verified feature back to waiting_approval
moveFeature(featureId, 'waiting_approval');
persistFeatureUpdate(featureId, { status: 'waiting_approval' }); persistFeatureUpdate(featureId, { status: 'waiting_approval' });
toast.info('Feature moved back', { toast.info('Feature moved back', {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice( description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
@@ -337,7 +329,6 @@ export function useBoardDragDrop({
}); });
} else if (targetStatus === 'backlog') { } else if (targetStatus === 'backlog') {
// Allow moving verified cards back to backlog // Allow moving verified cards back to backlog
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' }); persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', { toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice( description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -351,7 +342,6 @@ export function useBoardDragDrop({
[ [
features, features,
runningAutoTasks, runningAutoTasks,
moveFeature,
updateFeature, updateFeature,
persistFeatureUpdate, persistFeatureUpdate,
handleStartImplementation, handleStartImplementation,

View File

@@ -87,37 +87,22 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
); );
// Subscribe to auto mode events for notifications (ding sound, toasts) // Subscribe to auto mode events for notifications (ding sound, toasts)
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root // Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root.
// Note: removeRunningTask is handled by useAutoMode — do NOT duplicate it here,
// as duplicate Zustand mutations cause re-render cascades (React error #185).
useEffect(() => { useEffect(() => {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.autoMode || !currentProject) return; if (!api?.autoMode || !currentProject) return;
const { removeRunningTask } = useAppStore.getState();
const projectId = currentProject.id;
const projectPath = currentProject.path; const projectPath = currentProject.path;
const unsubscribe = api.autoMode.onEvent((event) => { const unsubscribe = api.autoMode.onEvent((event) => {
// Check if event is for the current project by matching projectPath // Check if event is for the current project by matching projectPath
const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined; const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
if (eventProjectPath && eventProjectPath !== projectPath) { if (eventProjectPath && eventProjectPath !== projectPath) {
// Event is for a different project, ignore it
logger.debug(
`Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
);
return; return;
} }
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
// NOTE: auto_mode_feature_start and auto_mode_feature_complete are NOT handled here
// for feature list reloading. That is handled by useAutoModeQueryInvalidation which
// invalidates the features.all query on those events. Duplicate invalidation here
// caused a re-render cascade through DndContext that triggered React error #185
// (maximum update depth exceeded), crashing the board view with an infinite spinner
// when a new feature was added and moved to in_progress.
if (event.type === 'auto_mode_feature_complete') { if (event.type === 'auto_mode_feature_complete') {
// Play ding sound when feature is done (unless muted) // Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState(); const { muteDoneSound } = useAppStore.getState();
@@ -126,14 +111,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
audio.play().catch((err) => logger.warn('Could not play ding sound:', err)); audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
} }
} else if (event.type === 'auto_mode_error') { } else if (event.type === 'auto_mode_error') {
// Remove from running tasks // Show error toast (removeRunningTask is handled by useAutoMode, not here)
if (event.featureId) {
const eventBranchName =
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
}
// Show error toast
const isAuthError = const isAuthError =
event.errorType === 'authentication' || event.errorType === 'authentication' ||
(event.error && (event.error &&

View File

@@ -281,6 +281,10 @@ function VirtualizedList<Item extends VirtualListItem>({
); );
} }
// Stable empty Set to use as default prop value. Using `new Set()` inline in
// the destructuring creates a new reference on every render, defeating memo.
const EMPTY_FEATURE_IDS = new Set<string>();
export const KanbanBoard = memo(function KanbanBoard({ export const KanbanBoard = memo(function KanbanBoard({
activeFeature, activeFeature,
getColumnFeatures, getColumnFeatures,
@@ -317,7 +321,7 @@ export const KanbanBoard = memo(function KanbanBoard({
onOpenPipelineSettings, onOpenPipelineSettings,
isSelectionMode = false, isSelectionMode = false,
selectionTarget = null, selectionTarget = null,
selectedFeatureIds = new Set(), selectedFeatureIds = EMPTY_FEATURE_IDS,
onToggleFeatureSelection, onToggleFeatureSelection,
onToggleSelectionMode, onToggleSelectionMode,
onAiSuggest, onAiSuggest,

View File

@@ -77,14 +77,21 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
// Buffer for batching rapid output events into fewer setState calls. // Buffer for batching rapid output events into fewer setState calls.
// Content accumulates here and is flushed via requestAnimationFrame, // Content accumulates here and is flushed via requestAnimationFrame,
// ensuring at most one React re-render per animation frame (~60fps max). // ensuring at most one React re-render per animation frame (~60fps max).
// A fallback setTimeout ensures the buffer is flushed even when RAF is
// throttled (e.g., when the tab is in the background).
const pendingOutputRef = useRef(''); const pendingOutputRef = useRef('');
const rafIdRef = useRef<number | null>(null); const rafIdRef = useRef<number | null>(null);
const timerIdRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetPendingOutput = useCallback(() => { const resetPendingOutput = useCallback(() => {
if (rafIdRef.current !== null) { if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current); cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null; rafIdRef.current = null;
} }
if (timerIdRef.current !== null) {
clearTimeout(timerIdRef.current);
timerIdRef.current = null;
}
pendingOutputRef.current = ''; pendingOutputRef.current = '';
}, []); }, []);
@@ -162,7 +169,12 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
}, [resetPendingOutput]); }, [resetPendingOutput]);
const flushPendingOutput = useCallback(() => { const flushPendingOutput = useCallback(() => {
// Clear both scheduling handles to prevent duplicate flushes
rafIdRef.current = null; rafIdRef.current = null;
if (timerIdRef.current !== null) {
clearTimeout(timerIdRef.current);
timerIdRef.current = null;
}
const content = pendingOutputRef.current; const content = pendingOutputRef.current;
if (!content) return; if (!content) return;
pendingOutputRef.current = ''; pendingOutputRef.current = '';
@@ -192,13 +204,31 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
* *
* Uses requestAnimationFrame to batch rapid output events into at most * Uses requestAnimationFrame to batch rapid output events into at most
* one React state update per frame, preventing excessive re-renders. * one React state update per frame, preventing excessive re-renders.
* A fallback setTimeout(250ms) ensures the buffer is flushed even when
* RAF is throttled (e.g., when the tab is in the background).
* If the pending buffer reaches MAX_LOG_BUFFER_SIZE, flushes immediately
* to prevent unbounded memory growth.
*/ */
const appendLogs = useCallback( const appendLogs = useCallback(
(content: string) => { (content: string) => {
pendingOutputRef.current += content; pendingOutputRef.current += content;
// Flush immediately if buffer has reached the size limit
if (pendingOutputRef.current.length >= MAX_LOG_BUFFER_SIZE) {
flushPendingOutput();
return;
}
// Schedule a RAF flush if not already scheduled
if (rafIdRef.current === null) { if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(flushPendingOutput); rafIdRef.current = requestAnimationFrame(flushPendingOutput);
} }
// Schedule a fallback timer flush if not already scheduled,
// to handle cases where RAF is throttled (background tab)
if (timerIdRef.current === null) {
timerIdRef.current = setTimeout(flushPendingOutput, 250);
}
}, },
[flushPendingOutput] [flushPendingOutput]
); );

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

@@ -22,6 +22,8 @@ function arraysEqual(a: string[], b: string[]): boolean {
return a.every((id) => set.has(id)); return a.every((id) => set.has(id));
} }
const AUTO_MODE_POLLING_INTERVAL = 30000; const AUTO_MODE_POLLING_INTERVAL = 30000;
// Stable empty array reference to avoid re-renders from `[] !== []`
const EMPTY_TASKS: string[] = [];
/** /**
* Generate a worktree key for session storage * Generate a worktree key for session storage
@@ -77,8 +79,12 @@ function isPlanApprovalEvent(
* @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null) * @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null)
*/ */
export function useAutoMode(worktree?: WorktreeInfo) { export function useAutoMode(worktree?: WorktreeInfo) {
// Subscribe to stable action functions and scalar state via useShallow.
// IMPORTANT: Do NOT subscribe to autoModeByWorktree here. That object gets a
// new reference on every Zustand mutation to ANY worktree, which would re-render
// every useAutoMode consumer on every store change. Instead, we subscribe to the
// specific worktree's state below using a targeted selector.
const { const {
autoModeByWorktree,
setAutoModeRunning, setAutoModeRunning,
addRunningTask, addRunningTask,
removeRunningTask, removeRunningTask,
@@ -93,7 +99,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
addRecentlyCompletedFeature, addRecentlyCompletedFeature,
} = useAppStore( } = useAppStore(
useShallow((state) => ({ useShallow((state) => ({
autoModeByWorktree: state.autoModeByWorktree,
setAutoModeRunning: state.setAutoModeRunning, setAutoModeRunning: state.setAutoModeRunning,
addRunningTask: state.addRunningTask, addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask, removeRunningTask: state.removeRunningTask,
@@ -144,41 +149,109 @@ export function useAutoMode(worktree?: WorktreeInfo) {
[projects] [projects]
); );
// Get worktree-specific auto mode state // Get worktree-specific auto mode state using a TARGETED selector with
// VALUE-BASED equality. This is critical for preventing cascading re-renders
// in board view, where DndContext amplifies every parent re-render.
//
// Why value-based equality matters: Every Zustand `set()` call (including
// `addAutoModeActivity` which fires on every WS event) triggers all subscriber
// selectors to re-run. Even our targeted selector that reads a specific key
// would return a new object reference (from the spread in `removeRunningTask`
// etc.), causing a re-render even when the actual values haven't changed.
// By extracting primitives and comparing with a custom equality function,
// we only re-render when isRunning/runningTasks/maxConcurrency actually change.
const projectId = currentProject?.id; const projectId = currentProject?.id;
const worktreeAutoModeState = useMemo(() => { const worktreeKey = useMemo(
if (!projectId) () => (projectId ? getWorktreeKey(projectId, branchName) : null),
return { [projectId, branchName, getWorktreeKey]
isRunning: false, );
runningTasks: [],
branchName: null,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
};
const key = getWorktreeKey(projectId, branchName);
return (
autoModeByWorktree[key] || {
isRunning: false,
runningTasks: [],
branchName,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
}
);
}, [autoModeByWorktree, projectId, branchName, getWorktreeKey]);
const isAutoModeRunning = worktreeAutoModeState.isRunning; // Subscribe to this specific worktree's state using useShallow.
const runningAutoTasks = worktreeAutoModeState.runningTasks; // useShallow compares each property of the returned object with Object.is,
// Use the subscribed worktreeAutoModeState.maxConcurrency (from the reactive // so primitive properties (isRunning: boolean, maxConcurrency: number) are
// autoModeByWorktree store slice) so canStartNewTask stays reactive when // naturally stable. Only runningTasks (array) needs additional stabilization
// refreshStatus updates worktree state or when the global setting changes. // since filter()/spread creates new array references even for identical content.
// Falls back to the subscribed globalMaxConcurrency (also reactive) when no const { worktreeIsRunning, worktreeRunningTasksRaw, worktreeMaxConcurrency } = useAppStore(
// per-worktree value is set, and to DEFAULT_MAX_CONCURRENCY when no project. useShallow((state) => {
if (!worktreeKey) {
return {
worktreeIsRunning: false,
worktreeRunningTasksRaw: EMPTY_TASKS,
worktreeMaxConcurrency: undefined as number | undefined,
};
}
const wt = state.autoModeByWorktree[worktreeKey];
if (!wt) {
return {
worktreeIsRunning: false,
worktreeRunningTasksRaw: EMPTY_TASKS,
worktreeMaxConcurrency: undefined as number | undefined,
};
}
return {
worktreeIsRunning: wt.isRunning,
worktreeRunningTasksRaw: wt.runningTasks,
worktreeMaxConcurrency: wt.maxConcurrency,
};
})
);
// Stabilize runningTasks: useShallow uses Object.is per property, but
// runningTasks gets a new array ref after removeRunningTask/addRunningTask.
// Cache the previous value and only update when content actually changes.
const prevTasksRef = useRef<string[]>(EMPTY_TASKS);
const worktreeRunningTasks = useMemo(() => {
if (worktreeRunningTasksRaw === prevTasksRef.current) return prevTasksRef.current;
if (arraysEqual(prevTasksRef.current, worktreeRunningTasksRaw)) return prevTasksRef.current;
prevTasksRef.current = worktreeRunningTasksRaw;
return worktreeRunningTasksRaw;
}, [worktreeRunningTasksRaw]);
const isAutoModeRunning = worktreeIsRunning;
const runningAutoTasks = worktreeRunningTasks;
// Use worktreeMaxConcurrency (from the reactive per-key selector) so
// canStartNewTask stays reactive when refreshStatus updates worktree state
// or when the global setting changes.
const maxConcurrency = projectId const maxConcurrency = projectId
? (worktreeAutoModeState.maxConcurrency ?? globalMaxConcurrency) ? (worktreeMaxConcurrency ?? globalMaxConcurrency)
: DEFAULT_MAX_CONCURRENCY; : DEFAULT_MAX_CONCURRENCY;
// Check if we can start a new task based on concurrency limit // Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency; const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Batch addAutoModeActivity calls to reduce Zustand set() frequency.
// Without batching, each WS event (especially auto_mode_progress which fires
// rapidly during streaming) triggers a separate set() → all subscriber selectors
// re-evaluate → on mobile this overwhelms React's batching → crash.
// This batches activities in a ref and flushes them in a single set() call.
const pendingActivitiesRef = useRef<Parameters<typeof addAutoModeActivity>[0][]>([]);
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const batchedAddAutoModeActivity = useCallback(
(activity: Parameters<typeof addAutoModeActivity>[0]) => {
pendingActivitiesRef.current.push(activity);
if (!flushTimerRef.current) {
flushTimerRef.current = setTimeout(() => {
const batch = pendingActivitiesRef.current;
pendingActivitiesRef.current = [];
flushTimerRef.current = null;
// Flush all pending activities in a single store update
for (const act of batch) {
addAutoModeActivity(act);
}
}, 100);
}
},
[addAutoModeActivity]
);
// Cleanup flush timer on unmount
useEffect(() => {
return () => {
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
}
};
}, []);
// Ref to prevent refreshStatus and WebSocket handlers from overwriting optimistic state // Ref to prevent refreshStatus and WebSocket handlers from overwriting optimistic state
// during start/stop transitions. // during start/stop transitions.
const isTransitioningRef = useRef(false); const isTransitioningRef = useRef(false);
@@ -498,7 +571,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
case 'auto_mode_feature_start': case 'auto_mode_feature_start':
if (event.featureId) { if (event.featureId) {
addRunningTask(eventProjectId, eventBranchName, event.featureId); addRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'start', type: 'start',
message: `Started working on feature`, message: `Started working on feature`,
@@ -514,7 +587,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// briefly appear in backlog due to stale cache data // briefly appear in backlog due to stale cache data
addRecentlyCompletedFeature(event.featureId); addRecentlyCompletedFeature(event.featureId);
removeRunningTask(eventProjectId, eventBranchName, event.featureId); removeRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'complete', type: 'complete',
message: event.passes message: event.passes
@@ -551,7 +624,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.` ? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.`
: event.error; : event.error;
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'error', type: 'error',
message: errorMessage, message: errorMessage,
@@ -568,7 +641,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
case 'auto_mode_progress': case 'auto_mode_progress':
// Log progress updates (throttle to avoid spam) // Log progress updates (throttle to avoid spam)
if (event.featureId && event.content && event.content.length > 10) { if (event.featureId && event.content && event.content.length > 10) {
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'progress', type: 'progress',
message: event.content.substring(0, 200), // Limit message length message: event.content.substring(0, 200), // Limit message length
@@ -579,7 +652,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
case 'auto_mode_tool': case 'auto_mode_tool':
// Log tool usage // Log tool usage
if (event.featureId && event.tool) { if (event.featureId && event.tool) {
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'tool', type: 'tool',
message: `Using tool: ${event.tool}`, message: `Using tool: ${event.tool}`,
@@ -592,7 +665,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Log phase transitions (Planning, Action, Verification) // Log phase transitions (Planning, Action, Verification)
if (event.featureId && event.phase && event.message) { if (event.featureId && event.phase && event.message) {
logger.debug(`[AutoMode] Phase: ${event.phase} for ${event.featureId}`); logger.debug(`[AutoMode] Phase: ${event.phase} for ${event.featureId}`);
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: event.phase, type: event.phase,
message: event.message, message: event.message,
@@ -618,7 +691,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Log when planning phase begins // Log when planning phase begins
if (event.featureId && event.mode && event.message) { if (event.featureId && event.mode && event.message) {
logger.debug(`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`); logger.debug(`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`);
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'planning', type: 'planning',
message: event.message, message: event.message,
@@ -631,7 +704,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Log when plan is approved by user // Log when plan is approved by user
if (event.featureId) { if (event.featureId) {
logger.debug(`[AutoMode] Plan approved for ${event.featureId}`); logger.debug(`[AutoMode] Plan approved for ${event.featureId}`);
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'action', type: 'action',
message: event.hasEdits message: event.hasEdits
@@ -646,7 +719,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Log when plan is auto-approved (requirePlanApproval=false) // Log when plan is auto-approved (requirePlanApproval=false)
if (event.featureId) { if (event.featureId) {
logger.debug(`[AutoMode] Plan auto-approved for ${event.featureId}`); logger.debug(`[AutoMode] Plan auto-approved for ${event.featureId}`);
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'action', type: 'action',
message: 'Plan auto-approved, starting implementation...', message: 'Plan auto-approved, starting implementation...',
@@ -665,7 +738,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug( logger.debug(
`[AutoMode] Plan revision requested for ${event.featureId} (v${revisionEvent.planVersion})` `[AutoMode] Plan revision requested for ${event.featureId} (v${revisionEvent.planVersion})`
); );
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'planning', type: 'planning',
message: `Revising plan based on feedback (v${revisionEvent.planVersion})...`, message: `Revising plan based on feedback (v${revisionEvent.planVersion})...`,
@@ -681,7 +754,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug( logger.debug(
`[AutoMode] Task ${taskEvent.taskId} started for ${event.featureId}: ${taskEvent.taskDescription}` `[AutoMode] Task ${taskEvent.taskId} started for ${event.featureId}: ${taskEvent.taskDescription}`
); );
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'progress', type: 'progress',
message: `▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}`, message: `▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}`,
@@ -696,7 +769,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug( logger.debug(
`[AutoMode] Task ${taskEvent.taskId} completed for ${event.featureId} (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})` `[AutoMode] Task ${taskEvent.taskId} completed for ${event.featureId} (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`
); );
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'progress', type: 'progress',
message: `${taskEvent.taskId} done (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`, message: `${taskEvent.taskId} done (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`,
@@ -714,7 +787,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug( logger.debug(
`[AutoMode] Phase ${phaseEvent.phaseNumber} completed for ${event.featureId}` `[AutoMode] Phase ${phaseEvent.phaseNumber} completed for ${event.featureId}`
); );
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'action', type: 'action',
message: `Phase ${phaseEvent.phaseNumber} completed`, message: `Phase ${phaseEvent.phaseNumber} completed`,
@@ -742,7 +815,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug( logger.debug(
`[AutoMode] Summary saved for ${event.featureId}: ${summaryEvent.summary.substring(0, 100)}...` `[AutoMode] Summary saved for ${event.featureId}: ${summaryEvent.summary.substring(0, 100)}...`
); );
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'progress', type: 'progress',
message: `Summary: ${summaryEvent.summary.substring(0, 100)}...`, message: `Summary: ${summaryEvent.summary.substring(0, 100)}...`,
@@ -758,7 +831,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
branchName, branchName,
addRunningTask, addRunningTask,
removeRunningTask, removeRunningTask,
addAutoModeActivity, batchedAddAutoModeActivity,
getProjectIdFromPath, getProjectIdFromPath,
setPendingPlanApproval, setPendingPlanApproval,
setAutoModeRunning, setAutoModeRunning,
@@ -977,7 +1050,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
removeRunningTask(currentProject.id, branchName, featureId); removeRunningTask(currentProject.id, branchName, featureId);
logger.info('Feature stopped successfully:', featureId); logger.info('Feature stopped successfully:', featureId);
addAutoModeActivity({ batchedAddAutoModeActivity({
featureId, featureId,
type: 'complete', type: 'complete',
message: 'Feature stopped by user', message: 'Feature stopped by user',
@@ -993,7 +1066,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
throw error; throw error;
} }
}, },
[currentProject, branchName, removeRunningTask, addAutoModeActivity] [currentProject, branchName, removeRunningTask, batchedAddAutoModeActivity]
); );
return { return {

View File

@@ -13,6 +13,7 @@ import type { AutoModeEvent, SpecRegenerationEvent, StreamEvent } from '@/types/
import type { IssueValidationEvent } from '@automaker/types'; import type { IssueValidationEvent } from '@automaker/types';
import { debounce, type DebouncedFunction } from '@automaker/utils/debounce'; import { debounce, type DebouncedFunction } from '@automaker/utils/debounce';
import { useEventRecencyStore } from './use-event-recency'; import { useEventRecencyStore } from './use-event-recency';
import { isAnyFeatureTransitioning } from '@/lib/feature-transition-state';
/** /**
* Debounce configuration for auto_mode_progress invalidations * Debounce configuration for auto_mode_progress invalidations
@@ -31,8 +32,10 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_start', 'auto_mode_feature_start',
'auto_mode_feature_complete', 'auto_mode_feature_complete',
'auto_mode_error', 'auto_mode_error',
'auto_mode_started', // NOTE: auto_mode_started and auto_mode_stopped are intentionally excluded.
'auto_mode_stopped', // These events signal auto-loop state changes, NOT feature data changes.
// Including them caused unnecessary refetches that raced with optimistic
// updates during start/stop cycles, triggering React error #185 on mobile.
'plan_approval_required', 'plan_approval_required',
'plan_approved', 'plan_approved',
'plan_rejected', 'plan_rejected',
@@ -176,8 +179,12 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
// This allows polling to be disabled when WebSocket events are flowing // This allows polling to be disabled when WebSocket events are flowing
recordGlobalEvent(); recordGlobalEvent();
// Invalidate feature list for lifecycle events // Invalidate feature list for lifecycle events.
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type)) { // Skip invalidation when a feature is mid-transition (e.g., being cancelled)
// because persistFeatureUpdate already handles the optimistic cache update.
// Without this guard, auto_mode_error / auto_mode_stopped WS events race
// with the optimistic update and cause re-render cascades on mobile (React #185).
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type) && !isAnyFeatureTransitioning()) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProjectPath), queryKey: queryKeys.features.all(currentProjectPath),
}); });

View File

@@ -0,0 +1,19 @@
/**
* Lightweight module-level state tracking which features are mid-transition
* (e.g., being cancelled). Used by useAutoModeQueryInvalidation to skip
* redundant cache invalidations while persistFeatureUpdate is in flight.
*/
const transitioningFeatures = new Set<string>();
export function markFeatureTransitioning(featureId: string): void {
transitioningFeatures.add(featureId);
}
export function unmarkFeatureTransitioning(featureId: string): void {
transitioningFeatures.delete(featureId);
}
export function isAnyFeatureTransitioning(): boolean {
return transitioningFeatures.size > 0;
}

View File

@@ -2763,6 +2763,21 @@ export class HttpApiClient implements ElectronAPI {
headers?: Record<string, string>; headers?: Record<string, string>;
enabled?: boolean; enabled?: boolean;
}>; }>;
eventHooks?: Array<{
id: string;
trigger: string;
enabled: boolean;
action: Record<string, unknown>;
name?: string;
}>;
ntfyEndpoints?: Array<{
id: string;
name: string;
serverUrl: string;
topic: string;
authType: string;
enabled: boolean;
}>;
}; };
error?: string; error?: string;
}> => this.get('/api/settings/global'), }> => this.get('/api/settings/global'),

View File

@@ -600,10 +600,7 @@ function RootLayoutContent() {
// so updating them won't cause a visible re-render flash. // so updating them won't cause a visible re-render flash.
const serverHooks = (finalSettings as GlobalSettings).eventHooks ?? []; const serverHooks = (finalSettings as GlobalSettings).eventHooks ?? [];
const currentHooks = useAppStore.getState().eventHooks; const currentHooks = useAppStore.getState().eventHooks;
if ( if (JSON.stringify(serverHooks) !== JSON.stringify(currentHooks)) {
JSON.stringify(serverHooks) !== JSON.stringify(currentHooks) &&
serverHooks.length > 0
) {
logger.info( logger.info(
`[FAST_HYDRATE] Reconciling eventHooks from server (server=${serverHooks.length}, store=${currentHooks.length})` `[FAST_HYDRATE] Reconciling eventHooks from server (server=${serverHooks.length}, store=${currentHooks.length})`
); );

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.

View File

@@ -1044,6 +1044,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set((state) => { set((state) => {
const current = state.autoModeByWorktree[key]; const current = state.autoModeByWorktree[key];
if (!current) return state; if (!current) return state;
// Idempotent: skip if task is not in the list to avoid creating new
// object references that trigger unnecessary re-renders.
if (!current.runningTasks.includes(taskId)) return state;
return { return {
autoModeByWorktree: { autoModeByWorktree: {
...state.autoModeByWorktree, ...state.autoModeByWorktree,
@@ -1097,13 +1100,20 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
addRecentlyCompletedFeature: (featureId: string) => { addRecentlyCompletedFeature: (featureId: string) => {
set((state) => { set((state) => {
// Idempotent: skip if already tracked to avoid creating a new Set reference
// that triggers unnecessary re-renders in useBoardColumnFeatures.
if (state.recentlyCompletedFeatures.has(featureId)) return state;
const newSet = new Set(state.recentlyCompletedFeatures); const newSet = new Set(state.recentlyCompletedFeatures);
newSet.add(featureId); newSet.add(featureId);
return { recentlyCompletedFeatures: newSet }; return { recentlyCompletedFeatures: newSet };
}); });
}, },
clearRecentlyCompletedFeatures: () => set({ recentlyCompletedFeatures: new Set() }), clearRecentlyCompletedFeatures: () => {
// Idempotent: skip if already empty to avoid creating a new Set reference.
if (get().recentlyCompletedFeatures.size === 0) return;
set({ recentlyCompletedFeatures: new Set() });
},
setMaxConcurrency: (max) => set({ maxConcurrency: max }), setMaxConcurrency: (max) => set({ maxConcurrency: max }),
@@ -1496,7 +1506,11 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ eventHooks: hooks }); set({ eventHooks: hooks });
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ eventHooks: hooks }); await httpApi.settings.updateGlobal({
eventHooks: hooks,
// Signal the server that an empty array is intentional (not a wipe from stale state)
...(hooks.length === 0 ? { __allowEmptyEventHooks: true } : {}),
});
} catch (error) { } catch (error) {
logger.error('Failed to sync event hooks:', error); logger.error('Failed to sync event hooks:', error);
} }
@@ -1507,7 +1521,11 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ ntfyEndpoints: endpoints }); set({ ntfyEndpoints: endpoints });
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ ntfyEndpoints: endpoints }); await httpApi.settings.updateGlobal({
ntfyEndpoints: endpoints,
// Signal the server that an empty array is intentional (not a wipe from stale state)
...(endpoints.length === 0 ? { __allowEmptyNtfyEndpoints: true } : {}),
});
} catch (error) { } catch (error) {
logger.error('Failed to sync ntfy endpoints:', error); logger.error('Failed to sync ntfy endpoints:', error);
} }