7 Commits

Author SHA1 Message Date
gsxdsm
736e12d397 feat: Batch dev server logs and fix React module resolution order 2026-03-02 23:19:17 -08:00
gsxdsm
90be17fd79 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>
2026-03-02 23:17:28 -08:00
gsxdsm
fc6c69f03d Changes from fix/dev-server-hang 2026-03-02 23:03:36 -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
16 changed files with 276 additions and 88 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,31 +588,28 @@ 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
if (context.projectPath && clickUrl) {
try { try {
const url = new URL(clickUrl); const url = new URL(clickUrl);
// 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.pathname = '/board';
url.searchParams.set('featureId', context.featureId); url.searchParams.set('featureId', context.featureId);
} else if (context.projectPath) { } else {
url.pathname = '/board'; url.pathname = '/board';
} }
clickUrl = url.toString(); clickUrl = url.toString();
} catch (error) { } catch (error) {
// If URL parsing fails, log warning and use as-is // If URL parsing fails, log warning and use as-is
logger.warn( logger.warn(
`Failed to parse defaultClickUrl "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}` `Failed to parse click URL "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
}
logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`); logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`);

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

@@ -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.
@@ -115,16 +111,14 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Board view only reacts to events for the currently selected project // Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId; const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === 'auto_mode_feature_start') { // NOTE: auto_mode_feature_start and auto_mode_feature_complete are NOT handled here
// Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected // for feature list reloading. That is handled by useAutoModeQueryInvalidation which
logger.info( // invalidates the features.all query on those events. Duplicate invalidation here
`[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...` // caused a re-render cascade through DndContext that triggered React error #185
); // (maximum update depth exceeded), crashing the board view with an infinite spinner
loadFeatures(); // when a new feature was added and moved to in_progress.
} else if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed if (event.type === 'auto_mode_feature_complete') {
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) {
@@ -161,7 +155,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,7 @@ function VirtualizedList<Item extends VirtualListItem>({
); );
} }
export function KanbanBoard({ export const KanbanBoard = memo(function KanbanBoard({
activeFeature, activeFeature,
getColumnFeatures, getColumnFeatures,
backgroundImageStyle, backgroundImageStyle,
@@ -719,4 +720,4 @@ export function KanbanBoard({
</DragOverlay> </DragOverlay>
</div> </div>
); );
} });

View File

@@ -74,6 +74,20 @@ 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).
const pendingOutputRef = useRef('');
const rafIdRef = useRef<number | null>(null);
const resetPendingOutput = useCallback(() => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
pendingOutputRef.current = '';
}, []);
/** /**
* Fetch buffered logs from the server * Fetch buffered logs from the server
*/ */
@@ -130,6 +144,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 +159,14 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
serverError: null, serverError: null,
}); });
hasFetchedInitialLogs.current = false; hasFetchedInitialLogs.current = false;
}, []); }, [resetPendingOutput]);
const flushPendingOutput = useCallback(() => {
rafIdRef.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 +186,30 @@ 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.
*/
const appendLogs = useCallback(
(content: string) => {
pendingOutputRef.current += content;
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(flushPendingOutput);
}
},
[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 +236,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 +286,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

@@ -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) {

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',
], ],
}, },