fix(desktop): dedupe Linux tray D-Bus calls to stop main-thread freeze (#2459)
The tray controller called setToolTip and setContextMenu unconditionally on every snapshot push. On Linux both are synchronous D-Bus calls into plasmashell's StatusNotifierItem host. useTraySync debounced pushes at 120ms (up to ~8/sec during token streaming), so each push made 2 blocking D-Bus round-trips even when only dockBadgeCount changed or a token streamed into an already-listed session — i.e., tooltip and menu content didn't change. The native block has no JS-level log, is intermittent (depends on plasmashell load), and eventually freezes the Electron main thread until crash. setTitle and setImage were already deduped; setToolTip and setContextMenu were the gap. Mirror the existing lastX pattern: - tray.mjs: dedupe setToolTip (exact string compare via lastTooltip) and setContextMenu (via lastMenuKey, a lightweight signature of menu-affecting fields: sessions/approvals/usage). menuKey is a string concat, cheaper than buildMenu itself, so skipping buildMenu when the key matches also saves work. Cache-after-call ordering matches lastTitle/lastTooltip so a throw forces a retry rather than skipping one. destroy() resets both new vars. - useTraySync.ts: FLUSH_DEBOUNCE_MS 120 -> 500. Tray doesn't need sub-second updates; approvals are rare discrete events that flush through the debounce and the main app UI stays instant via SSE/stores. The first change is the real fix; the second is a complement. Either alone helps; together they eliminate the freeze under streaming.
This commit is contained in:
@@ -99,6 +99,8 @@ const toTemplateImage = (p) => {
|
||||
export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconPaths, statusIconPaths, onAction }) => {
|
||||
let tray = null;
|
||||
let lastTitle = null;
|
||||
let lastTooltip = null;
|
||||
let lastMenuKey = null;
|
||||
|
||||
// macOS auto-picks the @2x file next to each path and tints the alpha.
|
||||
// Windows uses the regular app icon and ignores template tinting.
|
||||
@@ -274,6 +276,22 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
||||
return Menu.buildFromTemplate(template);
|
||||
};
|
||||
|
||||
// Lightweight signature of the menu-affecting content — skips nativeImage
|
||||
// and click handlers that can't be serialized. Cheaper than buildMenu itself.
|
||||
const menuKey = (snapshot) => {
|
||||
const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
|
||||
const approvals = Array.isArray(snapshot.approvals) ? snapshot.approvals : [];
|
||||
const usage = snapshot.usage && typeof snapshot.usage === 'object' ? snapshot.usage : {};
|
||||
const groups = Array.isArray(usage.groups) ? usage.groups : [];
|
||||
return JSON.stringify({
|
||||
h: typeof snapshot.instanceName === 'string' ? snapshot.instanceName : '',
|
||||
s: sessions.map((s) => `${s.id}|${s.title}|${s.status}|${s.unseen}|${s.hasError}|${s.subtitle}|${s.directory}`),
|
||||
a: approvals.map((a) => `${a.id}|${a.kind}|${a.sessionId}|${a.sessionTitle}|${a.label}|${a.directory}`),
|
||||
u: usage.mode || '',
|
||||
g: groups.map((g) => `${g.provider}|${g.status}|${(Array.isArray(g.rows) ? g.rows : []).map((r) => `${r.label}|${r.value}`).join(',')}`),
|
||||
});
|
||||
};
|
||||
|
||||
const update = (rawSnapshot) => {
|
||||
const snapshot = rawSnapshot && typeof rawSnapshot === 'object' ? rawSnapshot : {};
|
||||
const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
|
||||
@@ -293,8 +311,16 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
||||
lastTitle = title;
|
||||
}
|
||||
applyIconState(computeIconState(counts));
|
||||
widget.setToolTip(computeTooltip(counts, sessions.length));
|
||||
widget.setContextMenu(buildMenu(snapshot));
|
||||
const tooltip = computeTooltip(counts, sessions.length);
|
||||
if (tooltip !== lastTooltip) {
|
||||
widget.setToolTip(tooltip);
|
||||
lastTooltip = tooltip;
|
||||
}
|
||||
const key = menuKey(snapshot);
|
||||
if (key !== lastMenuKey) {
|
||||
widget.setContextMenu(buildMenu(snapshot));
|
||||
lastMenuKey = key;
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
@@ -304,6 +330,8 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
||||
}
|
||||
tray = null;
|
||||
lastTitle = null;
|
||||
lastTooltip = null;
|
||||
lastMenuKey = null;
|
||||
iconState = null;
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import type { QuestionRequest } from '@/types/question';
|
||||
const TRAY_ACTION_EVENT = 'openchamber:tray-action';
|
||||
// Event-driven updates do the real work; this is just a slow safety net.
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const FLUSH_DEBOUNCE_MS = 120;
|
||||
const FLUSH_DEBOUNCE_MS = 500;
|
||||
// Pull the full cross-project session list periodically. SSE keeps the active
|
||||
// directory instant; this catches sessions created in directories this client
|
||||
// never opened (other worktrees, other projects, the TUI, …).
|
||||
@@ -468,8 +468,8 @@ export const useTraySync = (): void => {
|
||||
};
|
||||
|
||||
// Coalesce bursts (e.g. token-by-token streaming updates a store rapidly)
|
||||
// into a single push, while staying near-instant for discrete events like
|
||||
// a new session appearing.
|
||||
// into at most one push per FLUSH_DEBOUNCE_MS; discrete events surface within
|
||||
// that window. The main app UI stays instant via SSE/stores.
|
||||
const scheduleFlush = () => {
|
||||
if (disposed || flushTimer !== null) return;
|
||||
flushTimer = window.setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user