Stop the UI freeze: coalesce snapshot storms and bound chat rendering

Busy agents emitted a snapshot-changed broadcast per tool turn; every event
made the renderer reload the full snapshot while the main process re-read
whole audit tables synchronously, and ChatTimeline re-rendered every message
with per-row linear scans - quadratic work per keystroke of agent activity
until clicks stopped responding. Root fixes at each layer: all broadcasts
funnel through one 200ms-coalesced notifier (unit-tested), the renderer
reloads at most every 800ms with a trailing load, audit/access snapshot
reads use SQL limits instead of full-table scans, and the timeline renders
the latest 150 messages via O(n) lookup maps with a real show-earlier
control. cred.txt is now gitignored so the Composio key cannot leak.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-06 22:40:32 -04:00
1 parent 538bf20057
commit f13ef48a3e
8 files changed
+158 -25

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
/**
* Wraps a notification callback so bursts collapse into one trailing call.
* Agent run loops emit change events per tool turn; without coalescing every
* emission forces the renderer through a full snapshot reload, which starves
* the UI thread once the database grows.
*/
export function coalesce(fn: () => void, delayMs: number): () => void {
let timer: NodeJS.Timeout | null = null;
return () => {
if (timer) return;
timer = setTimeout(() => {
timer = null;
fn();
}, delayMs);
};
}
+13 -12
View File
@@ -27,6 +27,7 @@ import { KnowledgeService } from "../services/knowledge-service";
import { AutomationService } from "../services/automation-service";
import { MeetingService } from "../services/meeting-service";
import { SprintService } from "../services/sprint-service";
import { coalesce } from "./coalesce";
import { normalizeExternalBrowserUrl } from "./external-url";
export interface IpcDeps {
@@ -43,13 +44,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const providers = new ProviderService(deps.connection.db);
const providerOAuth = new ProviderOAuthService();
const skills = new SkillService(deps.connection.db, deps.artifactRoot);
const notifyDataChanged = () => {
const notifyDataChanged = coalesce(() => {
try {
if (!deps.mainWindow.isDestroyed() && !deps.mainWindow.webContents.isDestroyed()) {
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
}
} catch { /* renderer may have been disposed during agent run */ }
};
}, 200);
const taskExecution = new TaskExecutionService(deps.connection.db, providers, skills, notifyDataChanged);
const social = new SocialService(deps.connection.db);
const files = new FileService(deps.connection.db, deps.artifactRoot);
@@ -74,7 +75,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const mutate = <T>(operation: () => T): DesktopResult<AppSnapshot> => {
try {
deps.connection.transaction(operation);
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("operation_failed", error instanceof Error ? error.message : String(error));
@@ -95,7 +96,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
deps.connection.transaction(() => {
summary = onboarding.bootstrap(input);
});
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
notifyDataChanged();
return ok(summary);
} catch (error) {
return fail("onboarding_failed", error instanceof Error ? error.message : String(error));
@@ -114,10 +115,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.runTask, async (_event, input) => {
try {
await taskExecution.runTask(input);
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
notifyDataChanged();
return fail("task_run_failed", error instanceof Error ? error.message : String(error));
}
});
@@ -151,7 +152,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => {
try {
await providers.checkHealth(input.orgId, input.providerId);
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("provider_health_failed", error instanceof Error ? error.message : String(error));
@@ -196,7 +197,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
employeeId: input.employeeId,
});
emit({ phase: "active" });
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
notifyDataChanged();
return ok({ secret: null });
}
emit({ phase: "active" });
@@ -222,7 +223,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.createProviderResponse, async (_event, input) => {
try {
await providers.createResponse(input);
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("provider_response_failed", error instanceof Error ? error.message : String(error));
@@ -233,7 +234,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
await providers.streamResponse(input, (streamEvent) => {
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "provider-response-stream", event: streamEvent });
});
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("provider_stream_failed", error instanceof Error ? error.message : String(error));
@@ -242,7 +243,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.cancelProviderResponse, async (_event, input) => {
try {
await providers.cancelResponse(input);
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("provider_cancel_failed", error instanceof Error ? error.message : String(error));
@@ -269,7 +270,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.importFile, async (_event, input) => {
try {
const fileId = await files.importFile(input);
if (fileId) deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
if (fileId) notifyDataChanged();
return ok(fileId);
} catch (error) {
return fail("file_import_failed", error instanceof Error ? error.message : String(error));
@@ -142,7 +142,7 @@ export class SnapshotService {
employeeIdentities,
serviceIdentities: list(this.db, "service_identities", "created_at desc"),
roleGrants: list(this.db, "role_grants", "created_at desc"),
accessDecisions: list<AppSnapshot["accessDecisions"][number]>(this.db, "access_decisions", "created_at desc").slice(0, 200),
accessDecisions: recentList<AppSnapshot["accessDecisions"][number]>(this.db, "access_decisions", "created_at desc", 200),
orgDepartments: list(this.db, "org_departments", "name asc"),
hiringPolicies: list(this.db, "hiring_policies", "department_name asc"),
approvalRoutingRules: list(this.db, "approval_routing_rules", "created_at asc"),
@@ -159,7 +159,7 @@ export class SnapshotService {
evalRuns: list(this.db, "eval_runs", "created_at desc"),
prBotGates,
auditReplayRuns: list(this.db, "audit_replay_runs", "created_at desc"),
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
auditEvents: recentList<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc", 200),
documents: list(this.db, "documents", "updated_at desc"),
knowledgeEntries: list(this.db, "knowledge_entries", "updated_at desc"),
automationRules: list<AppSnapshot["automationRules"][number]>(this.db, "automation_rules", "priority asc")