mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-22 23:53:08 +00:00
Compare commits
7 Commits
54d69e907b
...
fix/dev-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736e12d397 | ||
|
|
90be17fd79 | ||
|
|
fc6c69f03d | ||
|
|
b2915f4de1 | ||
|
|
cf3d312eef | ||
|
|
341a6534e6 | ||
|
|
4a128efbf4 |
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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/');
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user