Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useEffect, useState, useMemo } from 'react';
import { memo, useEffect, useState, useMemo, useRef } from 'react';
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
Expand Down Expand Up @@ -69,21 +69,70 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
const [taskStatusMap, setTaskStatusMap] = useState<
Map<string, 'pending' | 'in_progress' | 'completed'>
>(new Map());
// Track last WebSocket event timestamp to know if we're receiving real-time updates
const [lastWsEventTimestamp, setLastWsEventTimestamp] = useState<number | null>(null);

// Determine if we should poll for updates
const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
const shouldFetchData = feature.status !== 'backlog';

// Track whether we're receiving WebSocket events (within threshold)
// Use a state to trigger re-renders when the WebSocket connection becomes stale
const [isReceivingWsEvents, setIsReceivingWsEvents] = useState(false);
const wsEventTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// WebSocket activity threshold in ms - if no events within this time, consider WS inactive
const WS_ACTIVITY_THRESHOLD = 10000;

// Update isReceivingWsEvents when we get new WebSocket events
useEffect(() => {
if (lastWsEventTimestamp !== null) {
// We just received an event, mark as active
setIsReceivingWsEvents(true);

// Clear any existing timeout
if (wsEventTimeoutRef.current) {
clearTimeout(wsEventTimeoutRef.current);
}

// Set a timeout to mark as inactive if no new events
wsEventTimeoutRef.current = setTimeout(() => {
setIsReceivingWsEvents(false);
}, WS_ACTIVITY_THRESHOLD);
}

return () => {
if (wsEventTimeoutRef.current) {
clearTimeout(wsEventTimeoutRef.current);
}
};
}, [lastWsEventTimestamp]);

// Polling interval logic:
// - If receiving WebSocket events: use longer interval (10s) as a fallback
// - If not receiving WebSocket events but in_progress: use normal interval (3s)
// - Otherwise: no polling
const pollingInterval = useMemo((): number | false => {
if (!(isCurrentAutoTask || feature.status === 'in_progress')) {
return false;
}
// If receiving WebSocket events, use longer polling interval as fallback
if (isReceivingWsEvents) {
return WS_ACTIVITY_THRESHOLD;
}
// Default polling interval
return 3000;
}, [isCurrentAutoTask, feature.status, isReceivingWsEvents]);

// Fetch fresh feature data for planSpec (store data can be stale for task progress)
const { data: freshFeature } = useFeature(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent,
pollingInterval: shouldPoll ? 3000 : false,
pollingInterval,
});

// Fetch agent output for parsing
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent,
pollingInterval: shouldPoll ? 3000 : false,
pollingInterval,
});

// Parse agent output into agentInfo
Expand Down Expand Up @@ -174,6 +223,9 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Only handle events for this feature
if (!('featureId' in event) || event.featureId !== feature.id) return;

// Update timestamp for any event related to this feature
setLastWsEventTimestamp(Date.now());

switch (event.type) {
case 'auto_mode_task_started':
if ('taskId' in event) {
Expand Down
9 changes: 9 additions & 0 deletions apps/ui/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
export { useAutoMode } from './use-auto-mode';
export { useBoardBackgroundSettings } from './use-board-background-settings';
export { useElectronAgent } from './use-electron-agent';
export {
useEventRecorder,
useEventRecency,
useEventRecencyStore,
getGlobalEventsRecent,
getEventsRecent,
createSmartPollingInterval,
EVENT_RECENCY_THRESHOLD,
} from './use-event-recency';
export { useGuidedPrompts } from './use-guided-prompts';
export { useKeyboardShortcuts } from './use-keyboard-shortcuts';
export { useMessageQueue } from './use-message-queue';
Expand Down
18 changes: 15 additions & 3 deletions apps/ui/src/hooks/queries/use-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
import type { Feature } from '@/store/app-store';

const FEATURES_REFETCH_ON_FOCUS = false;
const FEATURES_REFETCH_ON_RECONNECT = false;
/** Default polling interval for agent output when WebSocket is inactive */
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;

/**
* Fetch all features for a project
Expand Down Expand Up @@ -79,7 +82,11 @@ export function useFeature(
},
enabled: !!projectPath && !!featureId && enabled,
staleTime: STALE_TIMES.FEATURES,
refetchInterval: pollingInterval,
// When a polling interval is specified, disable it if WebSocket events are recent
refetchInterval:
pollingInterval === false || pollingInterval === undefined
? pollingInterval
: () => (getGlobalEventsRecent() ? false : pollingInterval),
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
});
Expand Down Expand Up @@ -119,14 +126,19 @@ export function useAgentOutput(
},
enabled: !!projectPath && !!featureId && enabled,
staleTime: STALE_TIMES.AGENT_OUTPUT,
// Use provided polling interval or default behavior
// Use provided polling interval or default smart behavior
refetchInterval:
pollingInterval !== undefined
? pollingInterval
: (query) => {
// Disable polling when WebSocket events are recent (within 5s)
// WebSocket invalidation handles updates in real-time
if (getGlobalEventsRecent()) {
return false;
}
// Only poll if we have data and it's not empty (indicating active task)
if (query.state.data && query.state.data.length > 0) {
return 5000; // 5 seconds
return AGENT_OUTPUT_POLLING_INTERVAL;
}
return false;
},
Expand Down
5 changes: 4 additions & 1 deletion apps/ui/src/hooks/queries/use-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';

interface SpecFileResult {
content: string;
Expand Down Expand Up @@ -98,6 +99,8 @@ export function useSpecRegenerationStatus(projectPath: string | undefined, enabl
},
enabled: !!projectPath && enabled,
staleTime: 5000, // Check every 5 seconds when active
refetchInterval: enabled ? 5000 : false,
// Disable polling when WebSocket events are recent (within 5s)
// WebSocket invalidation handles updates in real-time
refetchInterval: enabled ? () => (getGlobalEventsRecent() ? false : 5000) : false,
});
}
176 changes: 176 additions & 0 deletions apps/ui/src/hooks/use-event-recency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Event Recency Hook
*
* Tracks the timestamp of the last WebSocket event received.
* Used to conditionally disable polling when events are flowing
* through WebSocket (indicating the connection is healthy).
*/

import { useEffect, useCallback } from 'react';
import { create } from 'zustand';

/**
* Time threshold (ms) to consider events as "recent"
* If an event was received within this time, WebSocket is considered healthy
* and polling can be safely disabled.
*/
export const EVENT_RECENCY_THRESHOLD = 5000; // 5 seconds

/**
* Store for tracking event timestamps per query key
* This allows fine-grained control over which queries have received recent events
*/
interface EventRecencyState {
/** Map of query key (stringified) -> last event timestamp */
eventTimestamps: Record<string, number>;
/** Global last event timestamp (for any event) */
lastGlobalEventTimestamp: number;
/** Record an event for a specific query key */
recordEvent: (queryKey: string) => void;
/** Record a global event (useful for general WebSocket health) */
recordGlobalEvent: () => void;
/** Check if events are recent for a specific query key */
areEventsRecent: (queryKey: string) => boolean;
/** Check if any global events are recent */
areGlobalEventsRecent: () => boolean;
}

export const useEventRecencyStore = create<EventRecencyState>((set, get) => ({
eventTimestamps: {},
lastGlobalEventTimestamp: 0,

recordEvent: (queryKey: string) => {
const now = Date.now();
set((state) => ({
eventTimestamps: {
...state.eventTimestamps,
[queryKey]: now,
},
lastGlobalEventTimestamp: now,
}));
},

recordGlobalEvent: () => {
set({ lastGlobalEventTimestamp: Date.now() });
},

areEventsRecent: (queryKey: string) => {
const { eventTimestamps } = get();
const lastEventTime = eventTimestamps[queryKey];
if (!lastEventTime) return false;
return Date.now() - lastEventTime < EVENT_RECENCY_THRESHOLD;
},

areGlobalEventsRecent: () => {
const { lastGlobalEventTimestamp } = get();
if (!lastGlobalEventTimestamp) return false;
return Date.now() - lastGlobalEventTimestamp < EVENT_RECENCY_THRESHOLD;
},
}));

/**
* Hook to record event timestamps when WebSocket events are received.
* Should be called from WebSocket event handlers.
*
* @returns Functions to record events
*
* @example
* ```tsx
* const { recordEvent, recordGlobalEvent } = useEventRecorder();
*
* // In WebSocket event handler:
* api.autoMode.onEvent((event) => {
* recordGlobalEvent();
* if (event.featureId) {
* recordEvent(`features:${event.featureId}`);
* }
* });
* ```
*/
export function useEventRecorder() {
const recordEvent = useEventRecencyStore((state) => state.recordEvent);
const recordGlobalEvent = useEventRecencyStore((state) => state.recordGlobalEvent);

return { recordEvent, recordGlobalEvent };
}

/**
* Hook to check if WebSocket events are recent, used by queries
* to decide whether to enable/disable polling.
*
* @param queryKey - Optional specific query key to check
* @returns Object with recency check result and timestamp
*
* @example
* ```tsx
* const { areEventsRecent, areGlobalEventsRecent } = useEventRecency();
*
* // In query options:
* refetchInterval: areGlobalEventsRecent() ? false : 5000,
* ```
*/
export function useEventRecency(queryKey?: string) {
const areEventsRecent = useEventRecencyStore((state) => state.areEventsRecent);
const areGlobalEventsRecent = useEventRecencyStore((state) => state.areGlobalEventsRecent);
const lastGlobalEventTimestamp = useEventRecencyStore((state) => state.lastGlobalEventTimestamp);

const checkRecency = useCallback(
(key?: string) => {
if (key) {
return areEventsRecent(key);
}
return areGlobalEventsRecent();
},
[areEventsRecent, areGlobalEventsRecent]
);

return {
areEventsRecent: queryKey ? () => areEventsRecent(queryKey) : areEventsRecent,
areGlobalEventsRecent,
checkRecency,
lastGlobalEventTimestamp,
};
}

/**
* Utility function to create a refetchInterval that respects event recency.
* Returns false (no polling) if events are recent, otherwise returns the interval.
*
* @param defaultInterval - The polling interval to use when events aren't recent
* @returns A function suitable for React Query's refetchInterval option
*
* @example
* ```tsx
* const { data } = useQuery({
* queryKey: ['features'],
* queryFn: fetchFeatures,
* refetchInterval: createSmartPollingInterval(5000),
* });
* ```
*/
export function createSmartPollingInterval(defaultInterval: number) {
return () => {
const { areGlobalEventsRecent } = useEventRecencyStore.getState();
return areGlobalEventsRecent() ? false : defaultInterval;
};
}

/**
* Helper function to get current event recency state (for use outside React)
* Useful in query configurations where hooks can't be used directly.
*
* @returns Whether global events are recent
*/
export function getGlobalEventsRecent(): boolean {
return useEventRecencyStore.getState().areGlobalEventsRecent();
}

/**
* Helper function to get event recency for a specific query key (for use outside React)
*
* @param queryKey - The query key to check
* @returns Whether events for that query key are recent
*/
export function getEventsRecent(queryKey: string): boolean {
return useEventRecencyStore.getState().areEventsRecent(queryKey);
}
Loading