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 }) => {
|
export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconPaths, statusIconPaths, onAction }) => {
|
||||||
let tray = null;
|
let tray = null;
|
||||||
let lastTitle = null;
|
let lastTitle = null;
|
||||||
|
let lastTooltip = null;
|
||||||
|
let lastMenuKey = null;
|
||||||
|
|
||||||
// macOS auto-picks the @2x file next to each path and tints the alpha.
|
// macOS auto-picks the @2x file next to each path and tints the alpha.
|
||||||
// Windows uses the regular app icon and ignores template tinting.
|
// Windows uses the regular app icon and ignores template tinting.
|
||||||
@@ -274,6 +276,22 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
|||||||
return Menu.buildFromTemplate(template);
|
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 update = (rawSnapshot) => {
|
||||||
const snapshot = rawSnapshot && typeof rawSnapshot === 'object' ? rawSnapshot : {};
|
const snapshot = rawSnapshot && typeof rawSnapshot === 'object' ? rawSnapshot : {};
|
||||||
const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
|
const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
|
||||||
@@ -293,8 +311,16 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
|||||||
lastTitle = title;
|
lastTitle = title;
|
||||||
}
|
}
|
||||||
applyIconState(computeIconState(counts));
|
applyIconState(computeIconState(counts));
|
||||||
widget.setToolTip(computeTooltip(counts, sessions.length));
|
const tooltip = computeTooltip(counts, sessions.length);
|
||||||
widget.setContextMenu(buildMenu(snapshot));
|
if (tooltip !== lastTooltip) {
|
||||||
|
widget.setToolTip(tooltip);
|
||||||
|
lastTooltip = tooltip;
|
||||||
|
}
|
||||||
|
const key = menuKey(snapshot);
|
||||||
|
if (key !== lastMenuKey) {
|
||||||
|
widget.setContextMenu(buildMenu(snapshot));
|
||||||
|
lastMenuKey = key;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
@@ -304,6 +330,8 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
|||||||
}
|
}
|
||||||
tray = null;
|
tray = null;
|
||||||
lastTitle = null;
|
lastTitle = null;
|
||||||
|
lastTooltip = null;
|
||||||
|
lastMenuKey = null;
|
||||||
iconState = null;
|
iconState = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import type { QuestionRequest } from '@/types/question';
|
|||||||
const TRAY_ACTION_EVENT = 'openchamber:tray-action';
|
const TRAY_ACTION_EVENT = 'openchamber:tray-action';
|
||||||
// Event-driven updates do the real work; this is just a slow safety net.
|
// Event-driven updates do the real work; this is just a slow safety net.
|
||||||
const POLL_INTERVAL_MS = 5000;
|
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
|
// Pull the full cross-project session list periodically. SSE keeps the active
|
||||||
// directory instant; this catches sessions created in directories this client
|
// directory instant; this catches sessions created in directories this client
|
||||||
// never opened (other worktrees, other projects, the TUI, …).
|
// 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)
|
// Coalesce bursts (e.g. token-by-token streaming updates a store rapidly)
|
||||||
// into a single push, while staying near-instant for discrete events like
|
// into at most one push per FLUSH_DEBOUNCE_MS; discrete events surface within
|
||||||
// a new session appearing.
|
// that window. The main app UI stays instant via SSE/stores.
|
||||||
const scheduleFlush = () => {
|
const scheduleFlush = () => {
|
||||||
if (disposed || flushTimer !== null) return;
|
if (disposed || flushTimer !== null) return;
|
||||||
flushTimer = window.setTimeout(() => {
|
flushTimer = window.setTimeout(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user