From 2ec77677cb899c5ca3f11fe465ec76916cad2a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 16 Apr 2026 21:58:32 -0400 Subject: [PATCH] fix(renderer): classify DesktopEvent types to stop full-refresh fallthrough (D-05) All 14+ non-specially-handled events were pushing into the batcher with entityKeyFromEvent returning null, which set hasDomainChanged = true and triggered a full snapshot refresh on every agent-message, run-status, and heartbeat-status -- defeating the granular patch machinery. Per-type classification: - run-status -> granular run-updated (patchRun by runId) - heartbeat-status -> granular agent-updated (patchAgent by agentId) - agent-message -> granular task-updated (patchTask refetches comments) - new-message, thread-wait-state-updated, domain-changed -> full refresh, coalesced via 500ms batcher window - automation-fired, recovery-needed -> snapshot-irrelevant, skipped at event source Widen batcher window from 50ms to 500ms so bursts coalesce into one reload. Granular batches still flush at the same window cadence. --- src/renderer/hooks/useDesktopEvents.ts | 18 +++++++++++++++--- src/renderer/lib/ipc-batcher.ts | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) 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,