diff --git a/src/renderer/hooks/useDesktopEvents.ts b/src/renderer/hooks/useDesktopEvents.ts index e0994d67..7ace761b 100644 --- a/src/renderer/hooks/useDesktopEvents.ts +++ b/src/renderer/hooks/useDesktopEvents.ts @@ -1,6 +1,10 @@ import { useEffect, type MutableRefObject } from "react"; import type { DesktopEvent } from "@shared/types"; -import { createDomainEventBatcher, type GranularEventEntry } from "../lib/ipc-batcher"; +import { + createDomainEventBatcher, + isSnapshotIrrelevantEvent, + type GranularEventEntry, +} from "../lib/ipc-batcher"; import { useAppStore } from "../state/store"; export function useDesktopEvents( @@ -106,10 +110,13 @@ export function useDesktopEvents( } } + // 500ms window for full-refresh events (e.g. new-message, + // thread-wait-state-updated) so bursts coalesce into one reload. + // Granular entity events still flush at this window but are cheap. const batcher = createDomainEventBatcher( () => { void refreshRef.current(); }, (events) => { void handleGranularBatch(events); }, - 50, + 500, ); const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => { @@ -140,7 +147,12 @@ export function useDesktopEvents( store.getState().navigateTo(event.section, event.entityId); return; } - // All data-mutating events go through the enhanced batcher + // One-shot events that do not affect ProfileSnapshot -- handled by + // their own listeners elsewhere, never trigger a snapshot refresh. + if (isSnapshotIrrelevantEvent(event)) { + return; + } + // All remaining data-mutating events go through the enhanced batcher batcher.push(event); }); return () => { diff --git a/src/renderer/lib/ipc-batcher.ts b/src/renderer/lib/ipc-batcher.ts index b503b3e9..ae3b26d8 100644 --- a/src/renderer/lib/ipc-batcher.ts +++ b/src/renderer/lib/ipc-batcher.ts @@ -20,11 +20,34 @@ export function entityKeyFromEvent(event: DesktopEvent): GranularEventEntry | nu return { type: "agent-updated", entityId: event.agentId, action: event.action }; case "budget-changed": return { type: "budget-changed", entityId: event.companyId, action: "changed" }; + // run-status is a run-level state change -- map onto run entity so + // granular patchRun refetches the run record. + case "run-status": + return { type: "run-updated", entityId: event.runId, action: "status_changed" }; + // heartbeat-status mutates agent metadata (nextWakeAt / enabled) -- map + // onto agent entity so granular patchAgent refetches. + case "heartbeat-status": + return { type: "agent-updated", entityId: event.agentId, action: "updated" }; + // agent-message posts a new comment under a task; patchTask refetch will + // pick up the task's updated comment list. + case "agent-message": + return { type: "task-updated", entityId: event.taskId, action: "updated" }; default: return null; } } +// Events that do NOT affect ProfileSnapshot and therefore must not trigger +// a snapshot refresh -- they are handled exclusively by their own listeners. +const SNAPSHOT_IRRELEVANT_EVENT_TYPES = new Set([ + "automation-fired", + "recovery-needed", +]); + +export function isSnapshotIrrelevantEvent(event: DesktopEvent): boolean { + return SNAPSHOT_IRRELEVANT_EVENT_TYPES.has(event.type); +} + export function createDomainEventBatcher( onFullRefresh: () => void, onGranularBatch: (events: GranularEventEntry[]) => void,