Files
AgentCompany/src/renderer/hooks/useDesktopEvents.ts
T
ZacharyZhang-NY 487f2456ff refactor(badges): derive from snapshot; delete getSidebarBadges IPC chain
Badges were fetched via a dedicated IPC that aggregated counts on the
server, while the same data lived in ProfileSnapshot on the client.
Two paths to the same answer drift whenever one side mutates between
refreshes — the visible symptom being "badge = 1 unread" with an empty
inbox after a message gets marked read in another screen.

Delete the whole server path. Sidebar badges are now derived on the
client from the snapshot via `deriveSidebarBadges(snapshot, companyId)`
in `src/shared/sidebar-badges.ts`. One source of truth; drift is
physically impossible.

Deletions:
- database/company-settings.ts::getSidebarBadges (120-line SQL aggregation)
- database-base.ts::getSidebarBadges pass-through
- ipc-core-read-handlers.ts: handler
- preload/index.ts: bridge binding
- shared/contracts/channels.ts: channel name
- shared/contracts/desktop-api/companies.ts: API signature
- shared/contracts/schemas-badges.ts::sidebarBadgesSchema Zod schema
- renderer/state/slices/company.ts: `badges` field, `setBadges`,
  `refreshBadges`
- useDataLoader: both getSidebarBadges call sites
- useCompanyController: badge reset on company switch
- useDesktopEvents: refreshBadges call on granular patches
- tests/unit/store.test.ts: refreshBadges test (deleted code)

The snapshot's agentMessages already carries board-reader read state
via boardReadReceiptMap (`snapshot.ts:39-47` has always LEFT JOIN'd
agent_message_reads with agent_id = BOARD_READER_ID). So derivation
needs no schema change — the infrastructure was in place, just unused.

fix(communication/MessageList): render orphaned-reply messages at the
top level

The thread-flattening used `filteredMessages.filter(m => !m.parentMessageId)`
to pick top-level messages. When an agent replies to a company/incident
post with a DM (the CEO prompt explicitly instructs this in
wake-reasons/en.ts:119), the DM lands in the direct channel with
parentMessageId pointing to a message that lives in another channel.
That parent is not in `filteredMessages` for the direct channel, so the
reply had no parent to nest under AND was excluded from top-level. The
channel rendered as a blank pane even though exactly one message
matched — the "clicked Dr. X ↔ Dr. Y, got empty" symptom.

Fix: a message is top-level when it either has no parent OR its parent
is outside the current filtered view. Replies with in-view parents
still nest normally.
2026-04-18 21:38:34 -04:00

166 lines
6.0 KiB
TypeScript

import { useEffect, type MutableRefObject } from "react";
import type { DesktopEvent } from "@shared/types";
import {
createDomainEventBatcher,
isSnapshotIrrelevantEvent,
type GranularEventEntry,
} from "../lib/ipc-batcher";
import { useAppStore } from "../state/store";
export function useDesktopEvents(
refreshRef: MutableRefObject<() => Promise<void>>,
logBufferRef: MutableRefObject<Record<string, string>>,
incrementLogRevision: () => void,
) {
useEffect(() => {
void refreshRef.current();
const store = useAppStore;
async function handleGranularBatch(events: GranularEventEntry[]) {
const companyId = store.getState().snapshot?.currentCompanyId ?? null;
if (!companyId) {
void refreshRef.current();
return;
}
let needsFullRefresh = false;
for (const entry of events) {
// run-updated:completed touches too many entity types -- full refresh
if (entry.type === "run-updated" && entry.action === "completed") {
needsFullRefresh = true;
break;
}
try {
switch (entry.type) {
case "task-updated": {
if (entry.action === "deleted") {
store.getState().removeTask(entry.entityId);
} else {
const result = await window.agentCompany.getTask({ companyId, id: entry.entityId });
if (result.ok) {
store.getState().patchTask(result.data);
} else {
store.getState().removeTask(entry.entityId);
}
}
break;
}
case "run-updated": {
if (entry.action === "deleted") {
store.getState().removeRun(entry.entityId);
} else {
const result = await window.agentCompany.getRun({ companyId, id: entry.entityId });
if (result.ok) {
store.getState().patchRun(result.data);
} else {
store.getState().removeRun(entry.entityId);
}
}
break;
}
case "approval-updated": {
if (entry.action === "deleted") {
store.getState().removeApproval(entry.entityId);
} else {
const result = await window.agentCompany.getApproval({ companyId, id: entry.entityId });
if (result.ok) {
store.getState().patchApproval(result.data);
} else {
store.getState().removeApproval(entry.entityId);
}
}
break;
}
case "agent-updated": {
const result = await window.agentCompany.getAgent({ companyId, id: entry.entityId });
if (result.ok) {
store.getState().patchAgent(result.data);
} else {
needsFullRefresh = true;
}
break;
}
case "budget-changed": {
// Budget is a computed aggregate -- full refresh is needed
needsFullRefresh = true;
break;
}
default: {
// Unknown granular event type -- fall back (per D-05 / PERF-03)
needsFullRefresh = true;
break;
}
}
} catch (error) {
console.warn(
`[renderer/useDesktopEvents] granular fetch failed for ${entry.type}/${entry.entityId}; falling back to full refresh: ${error instanceof Error ? error.message : String(error)}`,
);
needsFullRefresh = true;
break;
}
if (needsFullRefresh) break;
}
if (needsFullRefresh) {
void refreshRef.current();
}
// Granular patches already update the snapshot arrays, and
// sidebar badges derive from the snapshot live — no separate
// badge refresh is needed.
}
// 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); },
500,
);
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
if (event.type === "run-log") {
const MAX_LOG_BYTES_PER_RUN = 512_000;
const MAX_TRACKED_RUNS = 50;
const existing = logBufferRef.current[event.runId] ?? "";
const updated = existing.length < MAX_LOG_BYTES_PER_RUN
? existing + event.chunk
: existing.slice(-MAX_LOG_BYTES_PER_RUN / 2) + event.chunk;
logBufferRef.current[event.runId] = updated;
const runIds = Object.keys(logBufferRef.current);
if (runIds.length > MAX_TRACKED_RUNS) {
for (const oldId of runIds.slice(0, runIds.length - MAX_TRACKED_RUNS)) {
delete logBufferRef.current[oldId];
}
}
incrementLogRevision();
return;
}
if (event.type === "shortcut") {
if (event.action === "toggle-command-palette") store.getState().toggleCommandPalette();
if (event.action === "refresh") void refreshRef.current();
return;
}
if (event.type === "notification-click") {
store.getState().navigateTo(event.section, event.entityId);
return;
}
// 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 () => {
unsubscribe();
batcher.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshRef and logBufferRef are stable refs; incrementLogRevision is a stable Zustand setter
}, []);
}