8 Commits

Author SHA1 Message Date
gsxdsm
f5752b662f fix: Allow empty eventHooks/ntfyEndpoints to reconcile from server
Remove the `length > 0` guards in fast-hydrate reconciliation that
prevented intentional empty-array clears from syncing across clients.
Server-side wipe protection (`__allowEmpty*` escape hatches) already
ensures empty arrays in the server are intentional.

Addresses PR #831 review feedback from CodeRabbit and Gemini.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:08:07 -08:00
gsxdsm
6f2394f17e Changes from fix/event-hook-endpoint 2026-03-03 19:59:55 -08:00
gsxdsm
dd7108a7a0 Fixes critical React crash on the Kanban board view (#830)
* Changes from fix/board-react-crash

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

* fix: Address PR #828 review feedback

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:52:44 -08:00
gsxdsm
b2915f4de1 refactor: Simplify click URL resolution logic 2026-03-02 21:36:33 -08:00
gsxdsm
cf3d312eef fix: Remove overly restrictive pattern from summary extraction regex 2026-03-02 21:36:33 -08:00
gsxdsm
341a6534e6 refactor: extract shared isBacklogLikeStatus helper and improve comments
Address PR #825 review feedback:
- Extract duplicated backlog-like status check into shared helper in constants.ts
- Improve spec-parser regex comment to clarify subsection preservation
- Add file path reference in row-actions.tsx comment for traceability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:36:33 -08:00
gsxdsm
4a128efbf4 Changes from fix/board-crash-new-feat 2026-03-02 21:36:33 -08:00
24 changed files with 537 additions and 240 deletions

View File

@@ -598,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => {
// Subscribe to all events and forward to this client // Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => { const unsubscribe = events.subscribe((type, payload) => {
logger.info('Event received:', { // Use debug level for high-frequency events to avoid log spam
// that causes progressive memory growth and server slowdown
const isHighFrequency =
type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress';
const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Event received:', {
type, type,
hasPayload: !!payload, hasPayload: !!payload,
payloadKeys: payload ? Object.keys(payload) : [],
wsReadyState: ws.readyState, wsReadyState: ws.readyState,
wsOpen: ws.readyState === WebSocket.OPEN,
}); });
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type, payload }); const message = JSON.stringify({ type, payload });
logger.info('Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as Record<string, unknown>)?.sessionId,
});
ws.send(message); ws.send(message);
} else { } else {
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState); logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
} }
}); });

View File

@@ -88,9 +88,13 @@ const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
}, },
]; ];
// Throttle output to prevent overwhelming WebSocket under heavy load // Throttle output to prevent overwhelming WebSocket under heavy load.
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback // 100ms (~10fps) is sufficient for readable log streaming while keeping
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency // WebSocket traffic manageable. The previous 4ms rate (~250fps) generated
// up to 250 events/sec which caused progressive browser slowdown from
// accumulated console logs, JSON serialization overhead, and React re-renders.
const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate
const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency
export interface DevServerInfo { export interface DevServerInfo {
worktreePath: string; worktreePath: string;

View File

@@ -588,29 +588,26 @@ export class EventHookService {
eventType: context.eventType, eventType: context.eventType,
}; };
// Build click URL with deep-link if project context is available // Resolve click URL: action-level overrides endpoint default
let clickUrl = action.clickUrl; let clickUrl = action.clickUrl || endpoint.defaultClickUrl;
if (!clickUrl && endpoint.defaultClickUrl) {
clickUrl = endpoint.defaultClickUrl; // Apply deep-link parameters to the resolved click URL
// If we have a project path and the click URL looks like the server URL, if (clickUrl && context.projectPath) {
// append deep-link path try {
if (context.projectPath && clickUrl) { const url = new URL(clickUrl);
try { // Add featureId as query param for deep linking to board with feature output modal
const url = new URL(clickUrl); if (context.featureId) {
// Add featureId as query param for deep linking to board with feature output modal url.pathname = '/board';
if (context.featureId) { url.searchParams.set('featureId', context.featureId);
url.pathname = '/board'; } else {
url.searchParams.set('featureId', context.featureId); url.pathname = '/board';
} else if (context.projectPath) {
url.pathname = '/board';
}
clickUrl = url.toString();
} catch (error) {
// If URL parsing fails, log warning and use as-is
logger.warn(
`Failed to parse defaultClickUrl "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
);
} }
clickUrl = url.toString();
} catch (error) {
// If URL parsing fails, log warning and use as-is
logger.warn(
`Failed to parse click URL "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
);
} }
} }

View File

@@ -214,10 +214,14 @@ export function extractSummary(text: string): string | null {
} }
// Check for ## Summary section (use last match) // Check for ## Summary section (use last match)
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi); // Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections
// (like "### Root Cause", "### Fix Applied") that belong to the summary content.
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi);
const sectionMatch = getLastMatch(sectionMatches); const sectionMatch = getLastMatch(sectionMatches);
if (sectionMatch) { if (sectionMatch) {
return truncate(sectionMatch[1].trim(), 500); const content = sectionMatch[1].trim();
// Keep full content (including ### subsections) up to max length
return content.length > 500 ? `${content.substring(0, 500)}...` : content;
} }
// Check for **Goal**: section (lite mode, use last match) // Check for **Goal**: section (lite mode, use last match)

View File

@@ -90,8 +90,8 @@ describe('DevServerService Event Types', () => {
// 2. Output & URL Detected // 2. Output & URL Detected
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n')); mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
// Throttled output needs a bit of time // Throttled output needs a bit of time (OUTPUT_THROTTLE_MS is 100ms)
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 250));
expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1); expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1);
expect(emittedEvents['dev-server:url-detected'].length).toBe(1); expect(emittedEvents['dev-server:url-detected'].length).toBe(1);
expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/'); expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/');

View File

@@ -1246,7 +1246,9 @@ describe('EventHookService', () => {
const options = mockFetch.mock.calls[0][1]; const options = mockFetch.mock.calls[0][1];
// Hook values should override endpoint defaults // Hook values should override endpoint defaults
expect(options.headers['Tags']).toBe('override-emoji,override-tag'); expect(options.headers['Tags']).toBe('override-emoji,override-tag');
expect(options.headers['Click']).toBe('https://override.example.com'); // Click URL uses hook-specific base URL with deep link params applied
expect(options.headers['Click']).toContain('https://override.example.com/board');
expect(options.headers['Click']).toContain('featureId=feat-1');
expect(options.headers['Priority']).toBe('5'); expect(options.headers['Priority']).toBe('5');
}); });
@@ -1359,7 +1361,7 @@ describe('EventHookService', () => {
expect(clickUrl).not.toContain('featureId='); expect(clickUrl).not.toContain('featureId=');
}); });
it('should use hook-specific click URL overriding default with featureId', async () => { it('should apply deep link params to hook-specific click URL', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
status: 200, status: 200,
@@ -1409,8 +1411,9 @@ describe('EventHookService', () => {
const options = mockFetch.mock.calls[0][1]; const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click']; const clickUrl = options.headers['Click'];
// Should use the hook-specific click URL (not modified with featureId since it's a custom URL) // Should use the hook-specific click URL with deep link params applied
expect(clickUrl).toBe('https://custom.example.com/custom-page'); expect(clickUrl).toContain('https://custom.example.com/board');
expect(clickUrl).toContain('featureId=feat-789');
}); });
it('should preserve existing query params when adding featureId', async () => { it('should preserve existing query params when adding featureId', async () => {

View File

@@ -573,6 +573,55 @@ Implementation details.
`; `;
expect(extractSummary(text)).toBe('Summary content here.'); expect(extractSummary(text)).toBe('Summary content here.');
}); });
it('should include ### subsections within the summary (not cut off at ### Root Cause)', () => {
const text = `
## Summary
Overview of changes.
### Root Cause
The bug was caused by X.
### Fix Applied
Changed Y to Z.
## Other Section
More content.
`;
const result = extractSummary(text);
expect(result).not.toBeNull();
expect(result).toContain('Overview of changes.');
expect(result).toContain('### Root Cause');
expect(result).toContain('The bug was caused by X.');
expect(result).toContain('### Fix Applied');
expect(result).toContain('Changed Y to Z.');
expect(result).not.toContain('## Other Section');
});
it('should include ### subsections and stop at next ## header', () => {
const text = `
## Summary
Brief intro.
### Changes
- File A modified
- File B added
### Notes
Important context.
## Implementation
Details here.
`;
const result = extractSummary(text);
expect(result).not.toBeNull();
expect(result).toContain('Brief intro.');
expect(result).toContain('### Changes');
expect(result).toContain('### Notes');
expect(result).not.toContain('## Implementation');
});
}); });
describe('**Goal**: section (lite planning mode)', () => { describe('**Goal**: section (lite planning mode)', () => {
@@ -692,7 +741,7 @@ Summary section content.
expect(extractSummary('Random text without any summary patterns')).toBeNull(); expect(extractSummary('Random text without any summary patterns')).toBeNull();
}); });
it('should handle multiple paragraph summaries (return first paragraph)', () => { it('should include all paragraphs in ## Summary section', () => {
const text = ` const text = `
## Summary ## Summary
@@ -702,7 +751,9 @@ Second paragraph of summary.
## Other ## Other
`; `;
expect(extractSummary(text)).toBe('First paragraph of summary.'); const result = extractSummary(text);
expect(result).toContain('First paragraph of summary.');
expect(result).toContain('Second paragraph of summary.');
}); });
}); });

View File

@@ -83,7 +83,7 @@ import type {
StashApplyConflictInfo, StashApplyConflictInfo,
} from './board-view/worktree-panel/types'; } from './board-view/worktree-panel/types';
import { BoardErrorBoundary } from './board-view/board-error-boundary'; import { BoardErrorBoundary } from './board-view/board-error-boundary';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants'; import { COLUMNS, getColumnsWithPipeline, isBacklogLikeStatus } from './board-view/constants';
import { import {
useBoardFeatures, useBoardFeatures,
useBoardDragDrop, useBoardDragDrop,
@@ -1905,7 +1905,10 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
selectedFeatureIds={selectedFeatureIds} selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection} onToggleFeatureSelection={toggleFeatureSelection}
onRowClick={(feature) => { onRowClick={(feature) => {
if (feature.status === 'backlog') { // Running features should always show logs, even if status is
// stale (still 'backlog'/'ready'/'interrupted' during race window)
const isRunning = runningAutoTasksAllWorktrees.includes(feature.id);
if (isBacklogLikeStatus(feature.status) && !isRunning) {
setEditingFeature(feature); setEditingFeature(feature);
} else { } else {
handleViewOutput(feature); handleViewOutput(feature);

View File

@@ -30,6 +30,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import type { Feature } from '@/store/app-store'; import type { Feature } from '@/store/app-store';
import { isBacklogLikeStatus } from '../../constants';
/** /**
* Action handler types for row actions * Action handler types for row actions
@@ -431,6 +432,42 @@ export const RowActions = memo(function RowActions({
</> </>
)} )}
{/* Running task with stale status - the feature is tracked as running but its
persisted status hasn't caught up yet during WebSocket/cache sync delays.
These features are placed in the in_progress column by useBoardColumnFeatures
(hooks/use-board-column-features.ts) but no other menu block matches their
stale status, so we provide running-appropriate actions here. */}
{!isCurrentAutoTask && isRunningTask && isBacklogLikeStatus(feature.status) && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onForceStop && (
<>
<DropdownMenuSeparator />
<MenuItem
icon={StopCircle}
label="Force Stop"
onClick={withClose(handlers.onForceStop)}
variant="destructive"
/>
</>
)}
</>
)}
{/* Backlog actions */} {/* Backlog actions */}
{!isCurrentAutoTask && {!isCurrentAutoTask &&
!isRunningTask && !isRunningTask &&

View File

@@ -136,6 +136,26 @@ export function getPipelineInsertIndex(): number {
return BASE_COLUMNS.length; return BASE_COLUMNS.length;
} }
/**
* Statuses that display in the backlog column because they don't have dedicated columns:
* - 'backlog': Default state for new features
* - 'ready': Feature has an approved plan, waiting for execution
* - 'interrupted': Feature execution was aborted (user stopped it, server restart)
* - 'merge_conflict': Automatic merge failed, user must resolve conflicts
*
* Used to determine row click behavior and menu actions when a feature is running
* but its status hasn't updated yet (race condition during WebSocket/cache sync).
* See use-board-column-features.ts for the column assignment logic.
*/
export function isBacklogLikeStatus(status: string): boolean {
return (
status === 'backlog' ||
status === 'ready' ||
status === 'interrupted' ||
status === 'merge_conflict'
);
}
/** /**
* Check if a status is a pipeline status * Check if a status is a pipeline status
*/ */

View File

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

View File

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

View File

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

View File

@@ -32,11 +32,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const isRestoring = useIsRestoring(); const isRestoring = useIsRestoring();
// Use React Query for features // Use React Query for features
const { const { data: features = [], isLoading: isQueryLoading } = useFeatures(currentProject?.path);
data: features = [],
isLoading: isQueryLoading,
refetch: loadFeatures,
} = useFeatures(currentProject?.path);
// Don't report loading while IDB cache restore is in progress — // Don't report loading while IDB cache restore is in progress —
// features will appear momentarily once the restore completes. // features will appear momentarily once the restore completes.
@@ -91,40 +87,23 @@ 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 if (event.type === 'auto_mode_feature_complete') {
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === 'auto_mode_feature_start') {
// Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected
logger.info(
`[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...`
);
loadFeatures();
} else if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
logger.info('Feature completed, reloading features...');
loadFeatures();
// 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();
if (!muteDoneSound) { if (!muteDoneSound) {
@@ -132,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 &&
@@ -161,7 +133,6 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
}); });
return unsubscribe; return unsubscribe;
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadFeatures is a stable ref from React Query
}, [currentProject]); }, [currentProject]);
// Check for interrupted features on mount // Check for interrupted features on mount

View File

@@ -1,4 +1,5 @@
import { import {
memo,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -280,7 +281,11 @@ function VirtualizedList<Item extends VirtualListItem>({
); );
} }
export function KanbanBoard({ // 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({
activeFeature, activeFeature,
getColumnFeatures, getColumnFeatures,
backgroundImageStyle, backgroundImageStyle,
@@ -316,7 +321,7 @@ export 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,
@@ -719,4 +724,4 @@ export function KanbanBoard({
</DragOverlay> </DragOverlay>
</div> </div>
); );
} });

View File

@@ -74,6 +74,27 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
// Keep track of whether we've fetched initial logs // Keep track of whether we've fetched initial logs
const hasFetchedInitialLogs = useRef(false); const hasFetchedInitialLogs = useRef(false);
// Buffer for batching rapid output events into fewer setState calls.
// Content accumulates here and is flushed via requestAnimationFrame,
// 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 rafIdRef = useRef<number | null>(null);
const timerIdRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetPendingOutput = useCallback(() => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
if (timerIdRef.current !== null) {
clearTimeout(timerIdRef.current);
timerIdRef.current = null;
}
pendingOutputRef.current = '';
}, []);
/** /**
* Fetch buffered logs from the server * Fetch buffered logs from the server
*/ */
@@ -130,6 +151,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
* Clear logs and reset state * Clear logs and reset state
*/ */
const clearLogs = useCallback(() => { const clearLogs = useCallback(() => {
resetPendingOutput();
setState({ setState({
logs: '', logs: '',
logsVersion: 0, logsVersion: 0,
@@ -144,13 +166,19 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
serverError: null, serverError: null,
}); });
hasFetchedInitialLogs.current = false; hasFetchedInitialLogs.current = false;
}, []); }, [resetPendingOutput]);
const flushPendingOutput = useCallback(() => {
// Clear both scheduling handles to prevent duplicate flushes
rafIdRef.current = null;
if (timerIdRef.current !== null) {
clearTimeout(timerIdRef.current);
timerIdRef.current = null;
}
const content = pendingOutputRef.current;
if (!content) return;
pendingOutputRef.current = '';
/**
* Append content to logs, enforcing a maximum buffer size to prevent
* unbounded memory growth and progressive UI lag.
*/
const appendLogs = useCallback((content: string) => {
setState((prev) => { setState((prev) => {
const combined = prev.logs + content; const combined = prev.logs + content;
const didTrim = combined.length > MAX_LOG_BUFFER_SIZE; const didTrim = combined.length > MAX_LOG_BUFFER_SIZE;
@@ -170,6 +198,48 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
}); });
}, []); }, []);
/**
* Append content to logs, enforcing a maximum buffer size to prevent
* unbounded memory growth and progressive UI lag.
*
* Uses requestAnimationFrame to batch rapid output events into at most
* 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(
(content: string) => {
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) {
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]
);
// Clean up pending RAF on unmount to prevent state updates after unmount
useEffect(() => {
return () => {
resetPendingOutput();
};
}, [resetPendingOutput]);
// Fetch initial logs when worktreePath changes // Fetch initial logs when worktreePath changes
useEffect(() => { useEffect(() => {
if (worktreePath && autoSubscribe) { if (worktreePath && autoSubscribe) {
@@ -196,6 +266,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
switch (event.type) { switch (event.type) {
case 'dev-server:started': { case 'dev-server:started': {
resetPendingOutput();
const { payload } = event; const { payload } = event;
logger.info('Dev server started:', payload); logger.info('Dev server started:', payload);
setState((prev) => ({ setState((prev) => ({
@@ -245,7 +316,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
}); });
return unsubscribe; return unsubscribe;
}, [worktreePath, autoSubscribe, appendLogs]); }, [worktreePath, autoSubscribe, appendLogs, resetPendingOutput]);
return { return {
...state, ...state,

View File

@@ -4,13 +4,17 @@ import { getElectronAPI } from '@/lib/electron';
import { normalizePath } from '@/lib/utils'; import { normalizePath } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { DevServerInfo, WorktreeInfo } from '../types'; import type { DevServerInfo, WorktreeInfo } from '../types';
import { useEventRecencyStore } from '@/hooks/use-event-recency';
const logger = createLogger('DevServers'); const logger = createLogger('DevServers');
// Timeout (ms) for port detection before showing a warning to the user // Timeout (ms) for port detection before showing a warning to the user
const PORT_DETECTION_TIMEOUT_MS = 30_000; const PORT_DETECTION_TIMEOUT_MS = 30_000;
// Interval (ms) for periodic state reconciliation with the backend // Interval (ms) for periodic state reconciliation with the backend.
const STATE_RECONCILE_INTERVAL_MS = 5_000; // 30 seconds is sufficient since WebSocket events handle real-time updates;
// reconciliation is only a fallback for missed events (PWA restart, WS gaps).
// The previous 5-second interval added unnecessary HTTP pressure.
const STATE_RECONCILE_INTERVAL_MS = 30_000;
interface UseDevServersOptions { interface UseDevServersOptions {
projectPath: string; projectPath: string;
@@ -322,12 +326,24 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [clearPortDetectionTimer, startPortDetectionTimer]); }, [clearPortDetectionTimer, startPortDetectionTimer]);
// Record global events so smart polling knows WebSocket is healthy.
// Without this, dev-server events don't suppress polling intervals,
// causing all queries (features, worktrees, running-agents) to poll
// at their default rates even though the WebSocket is actively connected.
const recordGlobalEvent = useEventRecencyStore((state) => state.recordGlobalEvent);
// Subscribe to all dev server lifecycle events for reactive state updates // Subscribe to all dev server lifecycle events for reactive state updates
useEffect(() => { useEffect(() => {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.worktree?.onDevServerLogEvent) return; if (!api?.worktree?.onDevServerLogEvent) return;
const unsubscribe = api.worktree.onDevServerLogEvent((event) => { const unsubscribe = api.worktree.onDevServerLogEvent((event) => {
// Record that WS is alive (but only for lifecycle events, not output -
// output fires too frequently and would trigger unnecessary store updates)
if (event.type !== 'dev-server:output') {
recordGlobalEvent();
}
if (event.type === 'dev-server:starting') { if (event.type === 'dev-server:starting') {
const { worktreePath } = event.payload; const { worktreePath } = event.payload;
const key = normalizePath(worktreePath); const key = normalizePath(worktreePath);
@@ -424,7 +440,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
}); });
return unsubscribe; return unsubscribe;
}, [clearPortDetectionTimer, startPortDetectionTimer]); }, [clearPortDetectionTimer, startPortDetectionTimer, recordGlobalEvent]);
// Cleanup all port detection timers on unmount // Cleanup all port detection timers on unmount
useEffect(() => { useEffect(() => {

View File

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

View File

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

View File

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

View File

@@ -923,17 +923,20 @@ export class HttpApiClient implements ElectronAPI {
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
logger.info( // Only log non-high-frequency events to avoid progressive memory growth
'WebSocket message:', // from accumulated console entries. High-frequency events (dev-server output,
data.type, // test runner output, agent progress) fire 10+ times/sec and would generate
'hasPayload:', // thousands of console entries per minute.
!!data.payload, const isHighFrequency =
'callbacksRegistered:', data.type === 'dev-server:output' ||
this.eventCallbacks.has(data.type) data.type === 'test-runner:output' ||
); data.type === 'feature:progress' ||
(data.type === 'auto-mode:event' && data.payload?.type === 'auto_mode_progress');
if (!isHighFrequency) {
logger.info('WebSocket message:', data.type);
}
const callbacks = this.eventCallbacks.get(data.type); const callbacks = this.eventCallbacks.get(data.type);
if (callbacks) { if (callbacks) {
logger.info('Dispatching to', callbacks.size, 'callbacks');
callbacks.forEach((cb) => cb(data.payload)); callbacks.forEach((cb) => cb(data.payload));
} }
} catch (error) { } catch (error) {
@@ -2760,6 +2763,21 @@ export class HttpApiClient implements ElectronAPI {
headers?: Record<string, string>; headers?: Record<string, string>;
enabled?: boolean; enabled?: boolean;
}>; }>;
eventHooks?: Array<{
id: string;
trigger: string;
enabled: boolean;
action: Record<string, unknown>;
name?: string;
}>;
ntfyEndpoints?: Array<{
id: string;
name: string;
serverUrl: string;
topic: string;
authType: string;
enabled: boolean;
}>;
}; };
error?: string; error?: string;
}> => this.get('/api/settings/global'), }> => this.get('/api/settings/global'),

View File

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

View File

@@ -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);
} }

View File

@@ -238,24 +238,36 @@ export default defineConfig(({ command }) => {
// Inject build hash into sw.js CACHE_NAME for automatic cache busting // Inject build hash into sw.js CACHE_NAME for automatic cache busting
swCacheBuster(), swCacheBuster(),
], ],
// Keep Vite dep-optimization cache local to apps/ui so each worktree gets
// its own pre-bundled dependencies. Shared cache state across worktrees can
// produce duplicate React instances (notably with @xyflow/react) and trigger
// "Invalid hook call" in the graph view.
cacheDir: path.resolve(__dirname, 'node_modules/.vite'),
resolve: { resolve: {
alias: [ alias: [
{ find: '@', replacement: path.resolve(__dirname, './src') }, { find: '@', replacement: path.resolve(__dirname, './src') },
// Force ALL React imports (including from nested deps like zustand@4 inside // Force ALL React imports (including from nested deps like zustand@4 inside
// @xyflow/react) to resolve to the single copy in the workspace root node_modules. // @xyflow/react) to resolve to a single copy.
// This prevents "Cannot read properties of null (reading 'useState')" caused by // Explicit subpath aliases must come BEFORE the broad regex so Vite's
// react-dom setting the hooks dispatcher on one React instance while component // first-match-wins resolution applies the specific match first.
// code reads it from a different instance.
{ {
find: /^react-dom(\/|$)/, find: /^react-dom(\/|$)/,
replacement: path.resolve(__dirname, '../../node_modules/react-dom') + '/', replacement: path.resolve(__dirname, '../../node_modules/react-dom') + '/',
}, },
{
find: 'react/jsx-runtime',
replacement: path.resolve(__dirname, '../../node_modules/react/jsx-runtime.js'),
},
{
find: 'react/jsx-dev-runtime',
replacement: path.resolve(__dirname, '../../node_modules/react/jsx-dev-runtime.js'),
},
{ {
find: /^react(\/|$)/, find: /^react(\/|$)/,
replacement: path.resolve(__dirname, '../../node_modules/react') + '/', replacement: path.resolve(__dirname, '../../node_modules/react') + '/',
}, },
], ],
dedupe: ['react', 'react-dom'], dedupe: ['react', 'react-dom', 'zustand', 'use-sync-external-store', '@xyflow/react'],
}, },
server: { server: {
host: process.env.HOST || '0.0.0.0', host: process.env.HOST || '0.0.0.0',
@@ -355,8 +367,12 @@ export default defineConfig(({ command }) => {
include: [ include: [
'react', 'react',
'react-dom', 'react-dom',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'use-sync-external-store', 'use-sync-external-store',
'use-sync-external-store/shim',
'use-sync-external-store/shim/with-selector', 'use-sync-external-store/shim/with-selector',
'zustand',
'@xyflow/react', '@xyflow/react',
], ],
}, },