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:
pablogonzalez
2026-07-27 11:51:22 +03:00
committed by GitHub
parent e52d692ac0
commit 8aaef3dec1
2 changed files with 33 additions and 5 deletions
+30 -2
View File
@@ -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;
};
+3 -3
View File
@@ -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(() => {