From f13ef48a3ee53c835430a339a5a96ed56dec0dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 6 Jul 2026 22:40:32 -0400 Subject: [PATCH] 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. --- AgentCompany-New/.gitignore | 1 + AgentCompany-New/src/main/app/coalesce.ts | 16 +++++ AgentCompany-New/src/main/app/ipc-handlers.ts | 25 ++++---- .../src/main/services/snapshot-service.ts | 4 +- .../src/renderer/src/app/useSnapshot.ts | 28 ++++++++- .../src/pages/chat/ChatConversation.tsx | 1 + .../renderer/src/pages/chat/ChatTimeline.tsx | 59 ++++++++++++++++--- AgentCompany-New/tests/unit/coalesce.test.ts | 49 +++++++++++++++ 8 files changed, 158 insertions(+), 25 deletions(-) create mode 100644 AgentCompany-New/src/main/app/coalesce.ts create mode 100644 AgentCompany-New/tests/unit/coalesce.test.ts diff --git a/AgentCompany-New/.gitignore b/AgentCompany-New/.gitignore index 646b7730..1ee471ea 100644 --- a/AgentCompany-New/.gitignore +++ b/AgentCompany-New/.gitignore @@ -6,3 +6,4 @@ playwright-report/ coverage/ *.tsbuildinfo design-source/ +cred.txt diff --git a/AgentCompany-New/src/main/app/coalesce.ts b/AgentCompany-New/src/main/app/coalesce.ts new file mode 100644 index 00000000..60fa8cd0 --- /dev/null +++ b/AgentCompany-New/src/main/app/coalesce.ts @@ -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); + }; +} diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index de0c645c..509a33e0 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -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 = (operation: () => T): DesktopResult => { 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)); diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index ba06c5a5..ee010340 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -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(this.db, "access_decisions", "created_at desc").slice(0, 200), + accessDecisions: recentList(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(this.db, "audit_events", "created_at desc").slice(0, 200), + auditEvents: recentList(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(this.db, "automation_rules", "priority asc") diff --git a/AgentCompany-New/src/renderer/src/app/useSnapshot.ts b/AgentCompany-New/src/renderer/src/app/useSnapshot.ts index 9eee91a1..8f736466 100644 --- a/AgentCompany-New/src/renderer/src/app/useSnapshot.ts +++ b/AgentCompany-New/src/renderer/src/app/useSnapshot.ts @@ -29,10 +29,32 @@ export function useSnapshot(): { }, []); useEffect(() => { - void Promise.resolve().then(load); - return window.agentCompany.subscribe((event) => { - if (event.type === "snapshot-changed") void load(); + let disposed = false; + let timer: ReturnType | null = null; + let lastLoadAt = 0; + // Change events arrive in bursts while agents run; reload at most every + // 800ms with a trailing load so the UI converges without render storms. + const scheduleReload = () => { + if (disposed || timer) return; + const wait = Math.max(0, 800 - (Date.now() - lastLoadAt)); + timer = setTimeout(() => { + timer = null; + lastLoadAt = Date.now(); + void load(); + }, wait); + }; + void Promise.resolve().then(() => { + lastLoadAt = Date.now(); + return load(); }); + const unsubscribe = window.agentCompany.subscribe((event) => { + if (event.type === "snapshot-changed") scheduleReload(); + }); + return () => { + disposed = true; + if (timer) clearTimeout(timer); + unsubscribe(); + }; }, [load]); const actions = useMemo(() => ({ run, reload: load }), [run, load]); diff --git a/AgentCompany-New/src/renderer/src/pages/chat/ChatConversation.tsx b/AgentCompany-New/src/renderer/src/pages/chat/ChatConversation.tsx index 5e1c60f7..2b715e7c 100644 --- a/AgentCompany-New/src/renderer/src/pages/chat/ChatConversation.tsx +++ b/AgentCompany-New/src/renderer/src/pages/chat/ChatConversation.tsx @@ -72,6 +72,7 @@ export function ChatConversation({
{space && ( (items: T[], keyOf: (item: T) => string): Map { + const groups = new Map(); + for (const item of items) { + const key = keyOf(item); + const group = groups.get(key); + if (group) group.push(item); + else groups.set(key, [item]); + } + return groups; +} + export function ChatTimeline({ messagesInSpace, threadsInSpace, @@ -45,25 +61,52 @@ export function ChatTimeline({ onDeleteMessage: (messageId: string) => void; onOpenFile: (fileId: string) => void; }) { - const elements: ReactNode[] = []; - let lastDay = ""; + const [visibleCount, setVisibleCount] = useState(WINDOW_SIZE); + + const attachmentsByMessage = groupBy(attachments, (item) => item.messageId); + const mentionsByMessage = groupBy(mentions, (item) => item.messageId); + const invocationsByMessage = groupBy(slashInvocations, (item) => item.messageId); + const threadByRoot = new Map(); + for (const thread of threadsInSpace) { + if (thread.rootMessageId) threadByRoot.set(thread.rootMessageId, thread); + } + const replyCountByThread = new Map(); for (const message of messagesInSpace) { + if (!message.threadId) continue; + replyCountByThread.set(message.threadId, (replyCountByThread.get(message.threadId) ?? 0) + 1); + } + + const hiddenCount = Math.max(0, messagesInSpace.length - visibleCount); + const visible = hiddenCount > 0 ? messagesInSpace.slice(hiddenCount) : messagesInSpace; + + const elements: ReactNode[] = []; + if (hiddenCount > 0) { + elements.push( +
+ +
, + ); + } + let lastDay = ""; + for (const message of visible) { const day = formatDay(message.createdAt); if (day !== lastDay) { lastDay = day; elements.push(); } - const thread = threadsInSpace.find((item) => item.rootMessageId === message.id) ?? null; + const thread = threadByRoot.get(message.id) ?? null; const replyCount = thread - ? messagesInSpace.filter((m) => m.threadId === thread.id && m.id !== thread.rootMessageId).length + ? Math.max(0, (replyCountByThread.get(thread.id) ?? 0) - (thread.rootMessageId && message.threadId === thread.id ? 1 : 0)) : 0; elements.push( onOpenThread(thread.id) : null} diff --git a/AgentCompany-New/tests/unit/coalesce.test.ts b/AgentCompany-New/tests/unit/coalesce.test.ts new file mode 100644 index 00000000..9de4df9b --- /dev/null +++ b/AgentCompany-New/tests/unit/coalesce.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { coalesce } from "../../src/main/app/coalesce"; + +describe("coalesce", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("collapses a burst into one trailing call", () => { + const calls: number[] = []; + const notify = coalesce(() => calls.push(Date.now()), 200); + notify(); + notify(); + notify(); + expect(calls).toHaveLength(0); + vi.advanceTimersByTime(199); + expect(calls).toHaveLength(0); + vi.advanceTimersByTime(1); + expect(calls).toHaveLength(1); + }); + + test("fires again for events after the window closes", () => { + let count = 0; + const notify = coalesce(() => { count += 1; }, 200); + notify(); + vi.advanceTimersByTime(200); + expect(count).toBe(1); + notify(); + notify(); + vi.advanceTimersByTime(200); + expect(count).toBe(2); + }); + + test("an event during the pending window is covered by the trailing call", () => { + let count = 0; + const notify = coalesce(() => { count += 1; }, 200); + notify(); + vi.advanceTimersByTime(100); + notify(); + vi.advanceTimersByTime(100); + expect(count).toBe(1); + vi.advanceTimersByTime(200); + expect(count).toBe(1); + }); +});