mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
Compare commits
5 Commits
fix/dev-se
...
v1.1.0rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be8163b84 | ||
|
|
26b73df097 | ||
|
|
20e7c74b17 | ||
|
|
dd7108a7a0 | ||
|
|
ae48065820 |
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
19
apps/ui/src/lib/feature-transition-state.ts
Normal file
19
apps/ui/src/lib/feature-transition-state.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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})`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user