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:
1 parent
538bf20057
commit
f13ef48a3e
8 files changed
+158
-25
No files matched your search
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user