refactor(renderer): split snapshot state sections

This commit is contained in:
ZacharyZhang-NY committed 2026-04-24 02:48:28 -04:00
1 parent 236714e17a
commit 1383805d66
12 files changed
+1030 -899

No files matched your search

+7 -1
View File
@@ -3,6 +3,8 @@ import type { ProfileSnapshot, SectionId } from "@shared/types";
import { describeDesktopError, unwrap } from "../lib/desktop";
import { useAppStore } from "../state/store";
export type SnapshotRefreshGuard = () => boolean;
export function useDataLoader(
snapshot: ProfileSnapshot | null,
section: SectionId,
@@ -11,10 +13,11 @@ export function useDataLoader(
// selectedRunId via getState() at call time so its identity stays stable
// across connector/run selection changes and AppActions consumers do not
// cascade re-render.
const refresh = useCallback(async () => {
const refresh = useCallback(async (shouldApply: SnapshotRefreshGuard = () => true) => {
const store = useAppStore.getState();
try {
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
if (!shouldApply()) return;
store.setSnapshot(next);
if (next.companies.length === 0) {
store.setInitialOnboardingLocked(true);
@@ -26,12 +29,15 @@ export function useDataLoader(
if (next.currentCompanyId) {
store.setSocialAccounts(next.socialAccounts?.filter((a) => a.companyId === next.currentCompanyId) ?? []);
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
if (!shouldApply()) return;
if (browserActionResult.ok) {
store.setBrowserActions(browserActionResult.data);
}
}
if (!shouldApply()) return;
store.setError(null);
} catch (nextError) {
if (!shouldApply()) return;
store.setError(describeDesktopError(nextError));
}
}, []);
+37 -43
View File
@@ -1,37 +1,44 @@
import { useEffect, type MutableRefObject } from "react";
import type { DesktopEvent } from "@shared/types";
import {
createDomainEventBatcher,
isSnapshotIrrelevantEvent,
classifySnapshotEvent,
createSnapshotEventGuard,
type GranularEventEntry,
} from "../lib/ipc-batcher";
} from "../lib/snapshot-events";
import { useAppStore } from "../state/store";
export function useDesktopEvents(
refreshRef: MutableRefObject<() => Promise<void>>,
refreshRef: MutableRefObject<(shouldApply?: () => boolean) => Promise<void>>,
logBufferRef: MutableRefObject<Record<string, string>>,
incrementLogRevision: () => void,
) {
useEffect(() => {
void refreshRef.current();
const store = useAppStore;
const eventGuard = createSnapshotEventGuard();
async function handleGranularBatch(events: GranularEventEntry[]) {
function requestFullRefresh() {
eventGuard.invalidateSnapshot();
const token = eventGuard.captureSnapshot();
void refreshRef.current(() => eventGuard.canApplySnapshot(token));
}
requestFullRefresh();
async function handleSnapshotEntityEvent(entry: GranularEventEntry) {
const companyId = store.getState().snapshot?.currentCompanyId ?? null;
if (!companyId) {
void refreshRef.current();
requestFullRefresh();
return;
}
let needsFullRefresh = false;
eventGuard.invalidateSnapshot();
eventGuard.invalidateEntity(entry);
const token = eventGuard.captureEntity(entry);
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;
}
if (entry.type === "run-updated" && entry.action === "completed") {
needsFullRefresh = true;
} else {
try {
switch (entry.type) {
case "task-updated": {
@@ -39,6 +46,7 @@ export function useDesktopEvents(
store.getState().removeTask(entry.entityId);
} else {
const result = await window.agentCompany.getTask({ companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
store.getState().patchTask(result.data);
} else {
@@ -52,6 +60,7 @@ export function useDesktopEvents(
store.getState().removeRun(entry.entityId);
} else {
const result = await window.agentCompany.getRun({ companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
store.getState().patchRun(result.data);
} else {
@@ -65,6 +74,7 @@ export function useDesktopEvents(
store.getState().removeApproval(entry.entityId);
} else {
const result = await window.agentCompany.getApproval({ companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
store.getState().patchApproval(result.data);
} else {
@@ -75,6 +85,7 @@ export function useDesktopEvents(
}
case "agent-updated": {
const result = await window.agentCompany.getAgent({ companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
store.getState().patchAgent(result.data);
} else {
@@ -83,44 +94,24 @@ export function useDesktopEvents(
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)}`,
);
} catch {
needsFullRefresh = true;
break;
}
if (needsFullRefresh) break;
}
if (needsFullRefresh) {
void refreshRef.current();
requestFullRefresh();
}
// 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;
@@ -141,24 +132,27 @@ export function useDesktopEvents(
}
if (event.type === "shortcut") {
if (event.action === "toggle-command-palette") store.getState().toggleCommandPalette();
if (event.action === "refresh") void refreshRef.current();
if (event.action === "refresh") requestFullRefresh();
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)) {
const decision = classifySnapshotEvent(event);
if (decision.kind === "ignore") {
return;
}
// All remaining data-mutating events go through the enhanced batcher
batcher.push(event);
if (decision.kind === "full-refresh") {
requestFullRefresh();
return;
}
void handleSnapshotEntityEvent(decision.entry);
});
return () => {
unsubscribe();
batcher.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshRef and logBufferRef are stable refs; incrementLogRevision is a stable Zustand setter
}, []);
-113
View File
@@ -1,113 +0,0 @@
import type { DesktopEvent } from "@shared/types";
export interface GranularEventEntry {
type: string;
entityId: string;
action: string;
}
const MAX_GRANULAR_EVENTS = 10;
export function entityKeyFromEvent(event: DesktopEvent): GranularEventEntry | null {
switch (event.type) {
case "task-updated":
return { type: "task-updated", entityId: event.taskId, action: event.action };
case "run-updated":
return { type: "run-updated", entityId: event.runId, action: event.action };
case "approval-updated":
return { type: "approval-updated", entityId: event.approvalId, action: event.action };
case "agent-updated":
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<DesktopEvent["type"]>([
"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,
windowMs = 50,
maxGranular = MAX_GRANULAR_EVENTS,
) {
let timer: ReturnType<typeof setTimeout> | null = null;
let hasDomainChanged = false;
const pending = new Map<string, GranularEventEntry>();
function push(event: DesktopEvent) {
if (event.type === "domain-changed") {
hasDomainChanged = true;
} else {
const entry = entityKeyFromEvent(event);
if (entry) {
const key = `${entry.type}:${entry.entityId}`;
const existing = pending.get(key);
// If any event in window has action "deleted", preserve that action
if (existing && existing.action === "deleted" && entry.action !== "deleted") {
// Keep the delete -- it takes priority
} else {
pending.set(key, entry);
}
} else {
// Unknown event type -- treat as domain-changed for safety (per D-05)
hasDomainChanged = true;
}
}
if (timer === null) {
timer = setTimeout(flushInternal, windowMs);
}
}
function flushInternal() {
timer = null;
if (hasDomainChanged || pending.size > maxGranular) {
onFullRefresh();
} else if (pending.size > 0) {
onGranularBatch([...pending.values()]);
}
hasDomainChanged = false;
pending.clear();
}
function flush() {
if (timer !== null) {
clearTimeout(timer);
flushInternal();
}
}
function dispose() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
hasDomainChanged = false;
pending.clear();
}
return { push, flush, dispose };
}
+120
View File
@@ -0,0 +1,120 @@
import type { DesktopEvent } from "@shared/types";
export type SnapshotEntityEventType =
| "task-updated"
| "run-updated"
| "approval-updated"
| "agent-updated"
| "budget-changed";
export interface SnapshotEntityEvent {
type: SnapshotEntityEventType;
entityId: string;
action: string;
}
export type GranularEventEntry = SnapshotEntityEvent;
export type SnapshotEventDecision =
| { kind: "entity"; entry: SnapshotEntityEvent }
| { kind: "full-refresh" }
| { kind: "ignore" };
export interface SnapshotEntityToken {
snapshotEpoch: number;
entityVersion: number;
}
export interface SnapshotRefreshToken {
snapshotEpoch: number;
}
export interface SnapshotEventGuard {
invalidateSnapshot: () => void;
captureSnapshot: () => SnapshotRefreshToken;
canApplySnapshot: (token: SnapshotRefreshToken) => boolean;
invalidateEntity: (entry: SnapshotEntityEvent) => void;
captureEntity: (entry: SnapshotEntityEvent) => SnapshotEntityToken;
canApplyEntity: (entry: SnapshotEntityEvent, token: SnapshotEntityToken) => boolean;
}
export function classifySnapshotEvent(event: DesktopEvent): SnapshotEventDecision {
switch (event.type) {
case "task-updated":
return { kind: "entity", entry: { type: "task-updated", entityId: event.taskId, action: event.action } };
case "run-updated":
return { kind: "entity", entry: { type: "run-updated", entityId: event.runId, action: event.action } };
case "approval-updated":
return {
kind: "entity",
entry: { type: "approval-updated", entityId: event.approvalId, action: event.action },
};
case "agent-updated":
return { kind: "entity", entry: { type: "agent-updated", entityId: event.agentId, action: event.action } };
case "budget-changed":
return { kind: "entity", entry: { type: "budget-changed", entityId: event.companyId, action: "changed" } };
case "run-status":
return { kind: "entity", entry: { type: "run-updated", entityId: event.runId, action: "status_changed" } };
case "heartbeat-status":
return { kind: "entity", entry: { type: "agent-updated", entityId: event.agentId, action: "updated" } };
case "agent-message":
return { kind: "entity", entry: { type: "task-updated", entityId: event.taskId, action: "updated" } };
case "automation-fired":
case "recovery-needed":
case "run-log":
case "shortcut":
case "notification-click":
return { kind: "ignore" };
case "domain-changed":
case "new-message":
case "thread-wait-state-updated":
case "credentials-changed":
case "agent-event":
case "oauth:status":
case "oauth:auth-url":
case "oauth:progress":
case "oauth:prompt-request":
case "oauth:manual-code-request":
return { kind: "full-refresh" };
default:
return { kind: "full-refresh" };
}
}
export function createSnapshotEventGuard(): SnapshotEventGuard {
let snapshotEpoch = 0;
const entityVersions = new Map<string, number>();
function entityKey(entry: SnapshotEntityEvent): string {
return `${entry.type}:${entry.entityId}`;
}
function entityVersion(entry: SnapshotEntityEvent): number {
return entityVersions.get(entityKey(entry)) ?? 0;
}
return {
invalidateSnapshot() {
snapshotEpoch += 1;
},
captureSnapshot() {
return { snapshotEpoch };
},
canApplySnapshot(token) {
return snapshotEpoch === token.snapshotEpoch;
},
invalidateEntity(entry) {
const key = entityKey(entry);
entityVersions.set(key, (entityVersions.get(key) ?? 0) + 1);
},
captureEntity(entry) {
return {
snapshotEpoch,
entityVersion: entityVersion(entry),
};
},
canApplyEntity(entry, token) {
return snapshotEpoch === token.snapshotEpoch && entityVersion(entry) === token.entityVersion;
},
};
}
+61 -26
View File
@@ -16,6 +16,11 @@ import {
type AppErrorInput,
type AppErrorNotice,
} from "../../lib/desktop";
import {
assembleProfileSnapshot,
splitProfileSnapshot,
type ProfileSnapshotSections,
} from "../snapshot-sections";
// Sidebar badge counts and inbox items both live in the renderer —
// derived on every read from the snapshot (see `@shared/sidebar-badges`
@@ -24,6 +29,7 @@ import {
// slice is what let the counters drift against the rendered content.
export interface CompanySlice {
snapshot: ProfileSnapshot | null;
snapshotSections: ProfileSnapshotSections | null;
/**
* Bumps only on full-snapshot replacements (setSnapshot / refreshSnapshot).
* Granular patch/remove methods DO NOT bump this counter so that
@@ -49,6 +55,14 @@ export interface CompanySlice {
removeApproval: (approvalId: string) => void;
}
function applySnapshotSections(
state: CompanySlice,
sections: ProfileSnapshotSections | null,
) {
state.snapshotSections = sections;
state.snapshot = sections ? assembleProfileSnapshot(sections) : null;
}
export const createCompanySlice: StateCreator<
AppState,
[["zustand/immer", never]],
@@ -56,13 +70,14 @@ export const createCompanySlice: StateCreator<
CompanySlice
> = (set) => ({
snapshot: null,
snapshotSections: null,
snapshotRevision: 0,
companyMetrics: null,
standupReport: null,
error: null,
setSnapshot: (snapshot) =>
set((state) => {
state.snapshot = snapshot;
applySnapshotSections(state, snapshot ? splitProfileSnapshot(snapshot) : null);
state.snapshotRevision += 1;
}),
setCompanyMetrics: (metrics) =>
@@ -82,7 +97,7 @@ export const createCompanySlice: StateCreator<
const result = await window.agentCompany.loadSnapshot();
if (result.ok) {
set((state) => {
state.snapshot = result.data;
applySnapshotSections(state, splitProfileSnapshot(result.data));
state.snapshotRevision += 1;
});
} else {
@@ -101,65 +116,85 @@ export const createCompanySlice: StateCreator<
patchTask: (task) =>
set((state) => {
if (!state.snapshot) return;
const idx = state.snapshot.tasks.findIndex((t) => t.id === task.id);
if (!state.snapshotSections) return;
const tasks = state.snapshotSections.company.tasks;
const idx = tasks.findIndex((t) => t.id === task.id);
if (idx >= 0) {
state.snapshot.tasks[idx] = task;
tasks[idx] = task;
} else {
state.snapshot.tasks.unshift(task);
tasks.unshift(task);
}
applySnapshotSections(state, state.snapshotSections);
}),
removeTask: (taskId) =>
set((state) => {
if (!state.snapshot) return;
state.snapshot.tasks = state.snapshot.tasks.filter((t) => t.id !== taskId);
if (!state.snapshotSections) return;
state.snapshotSections.company.tasks = state.snapshotSections.company.tasks.filter(
(t) => t.id !== taskId,
);
applySnapshotSections(state, state.snapshotSections);
}),
patchRun: (run) =>
set((state) => {
if (!state.snapshot) return;
const idx = state.snapshot.runs.findIndex((r) => r.id === run.id);
if (!state.snapshotSections) return;
const runs = state.snapshotSections.company.runs;
const idx = runs.findIndex((r) => r.id === run.id);
if (idx >= 0) {
state.snapshot.runs[idx] = run;
runs[idx] = run;
} else {
state.snapshot.runs.unshift(run);
runs.unshift(run);
}
applySnapshotSections(state, state.snapshotSections);
}),
removeRun: (runId) =>
set((state) => {
if (!state.snapshot) return;
state.snapshot.runs = state.snapshot.runs.filter((r) => r.id !== runId);
if (!state.snapshotSections) return;
state.snapshotSections.company.runs = state.snapshotSections.company.runs.filter(
(r) => r.id !== runId,
);
applySnapshotSections(state, state.snapshotSections);
}),
patchAgent: (agent) =>
set((state) => {
if (!state.snapshot) return;
const idx = state.snapshot.agents.findIndex((a) => a.id === agent.id);
if (!state.snapshotSections) return;
const agents = state.snapshotSections.company.agents;
const idx = agents.findIndex((a) => a.id === agent.id);
if (idx >= 0) {
state.snapshot.agents[idx] = agent;
agents[idx] = agent;
} else {
state.snapshot.agents.unshift(agent);
agents.unshift(agent);
}
applySnapshotSections(state, state.snapshotSections);
}),
removeAgent: (agentId) =>
set((state) => {
if (!state.snapshot) return;
state.snapshot.agents = state.snapshot.agents.filter((a) => a.id !== agentId);
if (!state.snapshotSections) return;
state.snapshotSections.company.agents = state.snapshotSections.company.agents.filter(
(a) => a.id !== agentId,
);
applySnapshotSections(state, state.snapshotSections);
}),
patchApproval: (approval) =>
set((state) => {
if (!state.snapshot) return;
const idx = state.snapshot.approvals.findIndex((a) => a.id === approval.id);
if (!state.snapshotSections) return;
const approvals = state.snapshotSections.company.approvals;
const idx = approvals.findIndex((a) => a.id === approval.id);
if (idx >= 0) {
state.snapshot.approvals[idx] = approval;
approvals[idx] = approval;
} else {
state.snapshot.approvals.unshift(approval);
approvals.unshift(approval);
}
applySnapshotSections(state, state.snapshotSections);
}),
removeApproval: (approvalId) =>
set((state) => {
if (!state.snapshot) return;
state.snapshot.approvals = state.snapshot.approvals.filter((a) => a.id !== approvalId);
if (!state.snapshotSections) return;
state.snapshotSections.company.approvals = state.snapshotSections.company.approvals.filter(
(a) => a.id !== approvalId,
);
applySnapshotSections(state, state.snapshotSections);
}),
});
+116
View File
@@ -0,0 +1,116 @@
import type { ProfileSnapshot } from "@shared/types";
export interface ProfileMetadataSection {
currentCompanyId: ProfileSnapshot["currentCompanyId"];
profilePath: ProfileSnapshot["profilePath"];
updatesEnabled: ProfileSnapshot["updatesEnabled"];
theme: ProfileSnapshot["theme"];
locale: ProfileSnapshot["locale"];
backend: ProfileSnapshot["backend"];
}
export interface ProfileCatalogSection {
companies: ProfileSnapshot["companies"];
connectors: ProfileSnapshot["connectors"];
}
export interface CompanyCollectionsSection {
workspaces: ProfileSnapshot["workspaces"];
agents: ProfileSnapshot["agents"];
goals: ProfileSnapshot["goals"];
projects: ProfileSnapshot["projects"];
tasks: ProfileSnapshot["tasks"];
approvals: ProfileSnapshot["approvals"];
runs: ProfileSnapshot["runs"];
costs: ProfileSnapshot["costs"];
activity: ProfileSnapshot["activity"];
secrets: ProfileSnapshot["secrets"];
comments: ProfileSnapshot["comments"];
socialAccounts: ProfileSnapshot["socialAccounts"];
meetings: ProfileSnapshot["meetings"];
documents: ProfileSnapshot["documents"];
workProducts: ProfileSnapshot["workProducts"];
knowledgeBase: ProfileSnapshot["knowledgeBase"];
sprints: ProfileSnapshot["sprints"];
automationRules: ProfileSnapshot["automationRules"];
agentMessages: ProfileSnapshot["agentMessages"];
workflows: ProfileSnapshot["workflows"];
}
export interface ProfileSnapshotSections {
metadata: ProfileMetadataSection;
catalog: ProfileCatalogSection;
company: CompanyCollectionsSection;
}
export function splitProfileSnapshot(snapshot: ProfileSnapshot): ProfileSnapshotSections {
return {
metadata: {
currentCompanyId: snapshot.currentCompanyId,
profilePath: snapshot.profilePath,
updatesEnabled: snapshot.updatesEnabled,
theme: snapshot.theme,
locale: snapshot.locale,
backend: snapshot.backend,
},
catalog: {
companies: snapshot.companies,
connectors: snapshot.connectors,
},
company: {
workspaces: snapshot.workspaces,
agents: snapshot.agents,
goals: snapshot.goals,
projects: snapshot.projects,
tasks: snapshot.tasks,
approvals: snapshot.approvals,
runs: snapshot.runs,
costs: snapshot.costs,
activity: snapshot.activity,
secrets: snapshot.secrets,
comments: snapshot.comments,
socialAccounts: snapshot.socialAccounts,
meetings: snapshot.meetings,
documents: snapshot.documents,
workProducts: snapshot.workProducts,
knowledgeBase: snapshot.knowledgeBase,
sprints: snapshot.sprints,
automationRules: snapshot.automationRules,
agentMessages: snapshot.agentMessages,
workflows: snapshot.workflows,
},
};
}
export function assembleProfileSnapshot(sections: ProfileSnapshotSections): ProfileSnapshot {
return {
currentCompanyId: sections.metadata.currentCompanyId,
profilePath: sections.metadata.profilePath,
updatesEnabled: sections.metadata.updatesEnabled,
theme: sections.metadata.theme,
locale: sections.metadata.locale,
backend: sections.metadata.backend,
companies: sections.catalog.companies,
connectors: sections.catalog.connectors,
workspaces: sections.company.workspaces,
agents: sections.company.agents,
goals: sections.company.goals,
projects: sections.company.projects,
tasks: sections.company.tasks,
approvals: sections.company.approvals,
runs: sections.company.runs,
costs: sections.company.costs,
activity: sections.company.activity,
secrets: sections.company.secrets,
comments: sections.company.comments,
socialAccounts: sections.company.socialAccounts,
meetings: sections.company.meetings,
documents: sections.company.documents,
workProducts: sections.company.workProducts,
knowledgeBase: sections.company.knowledgeBase,
sprints: sections.company.sprints,
automationRules: sections.company.automationRules,
agentMessages: sections.company.agentMessages,
workflows: sections.company.workflows,
};
}