feat(desktop): macOS menu bar tray with live session state and mini-chat UX

Add an always-visible macOS status bar (tray) item that surfaces OpenChamber's
live state and acts as a quick launcher, plus a series of related desktop UX
fixes around mini-chat, window routing, notifications and shortcuts.

Tray (new):
- Monochrome template cube glyph that adapts to the menu bar light/dark.
- Icon-driven activity indicator: a smooth, eased, infinite "breathing" fill
  while sessions are busy; a static filled cube when finished sessions are left
  unread; a plain outline when idle. Text counters next to the icon only for
  actionable states (pending approvals, errors).
- Menu lists active sessions (status glyph, branch, unread count) with overflow
  rolled into a submenu; pending permission/question approvals with inline
  Allow once / Allow always / Deny; quick actions (New Session, New Mini Chat,
  Show OpenChamber, Quit). Header shows the active instance name
  ("Local OpenChamber" or the remote host label) for multi-window clarity.
- Session list sourced from the global (cross-project) sessions store, sorted by
  last-updated, independent of which directories are currently open; live
  status/unread/branch merged in from directory sync stores where available.
  Sub-session (multi-run) activity rolls up to the parent row.
- Event-driven updates (global store + directory stores + notifications +
  registry) with a short debounce; polling kept only as a slow safety net.

Tray/window routing:
- Opening a session from the tray targets the surface the user was last on: if a
  mini-chat is active it switches that existing window to the session in place
  (no new window); otherwise the main window (revealed without a reload).
- app.activate (dock click) restores the last-focused/minimized window instead
  of spawning a new main window; only creates one when nothing is left.
- "Open in main window" and tray session-open now create the main window when
  none exists, queuing the session as a pending deep-link so it opens once the
  fresh renderer is ready.

Mini chat:
- New Mini Chat is now a customizable shortcut, exposed in Settings > Shortcuts,
  in the File menu (hint only, renderer owns the binding), and in the tray.
- Themed splash backdrop on window open to remove the white flash / flicker;
  dismissed once content is ready, leaving the content's single cube logo.
- Mini-chat can switch sessions in place via openchamber:open-session.

Notifications:
- The active/selected session only counts as "seen" when the window is focused,
  so turns completing while the app is backgrounded raise an unread marker;
  refocusing the window clears it.
This commit is contained in:
Bohdan Triapitsyn
2026-06-10 00:20:23 +03:00
parent 161d11021c
commit 9cf79a8890
46 changed files with 1061 additions and 25 deletions
+223 -22
View File
@@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import updaterPkg from 'electron-updater';
import { ElectronSshManager } from './ssh-manager.mjs';
import { createTrayController } from './tray.mjs';
const execFileAsync = promisify(execFile);
@@ -186,6 +187,8 @@ const state = {
miniChatWindowsBySession: new Map(),
sshStatuses: new Map(),
sshLogs: new Map(),
trayController: null,
lastFocusedWindowId: null,
};
const quitRisk = {
@@ -249,6 +252,14 @@ const prepareForQuit = ({ installingUpdate = false } = {}) => {
state.installingUpdate = installingUpdate;
state.quitConfirmationPending = false;
if (state.trayController) {
try {
state.trayController.destroy();
} catch {
}
state.trayController = null;
}
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
try {
debounceWindowStatePersist(state.mainWindow, true);
@@ -1738,6 +1749,14 @@ const dispatchMenuAction = (action) => {
dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
};
// Mini-chat draft windows are not deduplicated, so this must reach the renderer
// exactly once — emitToWindow alone (no DOM-event double dispatch). The renderer
// resolves the active directory/project and opens the window.
const dispatchOpenMiniChat = (browserWindow) => {
const target = browserWindow && !browserWindow.isDestroyed() ? browserWindow : getMenuTargetWindow();
if (target) emitToWindow(target, 'openchamber:open-mini-chat');
};
const dispatchCheckForUpdates = () => {
emitToAllWindows('openchamber:check-for-updates');
for (const browserWindow of BrowserWindow.getAllWindows()) {
@@ -3134,6 +3153,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
maybeShowNativeNotification(args);
return null;
case 'desktop_tray_update':
if (state.trayController) {
try {
state.trayController.update(args || {});
} catch (error) {
log.warn('[electron] tray update failed', error);
}
}
return null;
case 'desktop_clear_cache':
await session.defaultSession.clearStorageData();
for (const browserWindow of BrowserWindow.getAllWindows()) {
@@ -3568,23 +3597,33 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
case 'desktop_get_window_pinned':
return { pinned: Boolean(browserWindow?.__ocPinned) };
case 'desktop_focus_main_window':
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
if (state.mainWindow.isMinimized()) state.mainWindow.restore();
state.mainWindow.show();
state.mainWindow.focus();
const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
const directory = typeof args.directory === 'string' ? args.directory.trim() : '';
const mode = typeof args.mode === 'string' ? args.mode.trim() : '';
if (sessionId) {
emitToWindow(state.mainWindow, 'openchamber:open-session', { sessionId, directory });
} else if (mode === 'draft') {
const projectId = typeof args.projectId === 'string' ? args.projectId.trim() : '';
emitToWindow(state.mainWindow, 'openchamber:open-draft-session', { directory, projectId });
}
case 'desktop_focus_main_window': {
const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
const directory = typeof args.directory === 'string' ? args.directory.trim() : '';
const mode = typeof args.mode === 'string' ? args.mode.trim() : '';
const projectId = typeof args.projectId === 'string' ? args.projectId.trim() : '';
const hasMainWindow = state.mainWindow && !state.mainWindow.isDestroyed();
// No live main window (e.g. "Open in main window" from a mini-chat after
// the main window was closed): create one and open the session in it. A
// fresh window can't take an immediate emit, so queue the session as a
// pending deep-link and let did-finish-load flush it once ready.
if (!hasMainWindow) {
if (sessionId) pendingDeepLinks.push({ type: 'session', value: sessionId });
await openMainWindow();
return { focused: true };
}
return { focused: false };
if (state.mainWindow.isMinimized()) state.mainWindow.restore();
state.mainWindow.show();
state.mainWindow.focus();
if (sessionId) {
emitToWindow(state.mainWindow, 'openchamber:open-session', { sessionId, directory });
} else if (mode === 'draft') {
emitToWindow(state.mainWindow, 'openchamber:open-draft-session', { directory, projectId });
}
return { focused: true };
}
case 'desktop_close_current_window':
if (browserWindow && !browserWindow.isDestroyed()) {
@@ -3700,6 +3739,9 @@ const buildMacMenu = () => {
{ type: 'separator' },
{ label: 'New Session', accelerator: 'Cmd+N', click: () => dispatchAction('new-session') },
{ label: 'New Worktree', accelerator: 'Cmd+Shift+N', click: () => dispatchAction('new-worktree-session') },
// registerAccelerator:false → show the shortcut hint but let the
// renderer own the (customizable) key binding, avoiding a double open.
{ label: 'New Mini Chat', accelerator: 'Cmd+Alt+N', registerAccelerator: false, click: () => dispatchOpenMiniChat() },
{ type: 'separator' },
{ label: 'Add Workspace', click: () => dispatchAction('change-workspace') },
{ type: 'separator' },
@@ -4006,6 +4048,157 @@ ipcMain.handle('openchamber:dialog:open', async (event, options) => {
return result.filePaths[0] || null;
});
// --- macOS menu bar (status bar) ---------------------------------------------
// Tray lives only on macOS; the renderer streams a compact state snapshot via
// the `desktop_tray_update` IPC command (see the command switch). Tray clicks
// flow back through dispatchTrayAction → renderer (focus/respond) or native
// handlers (show window / quit).
// Icon assets: a calm outline (idle), a statically filled cube (a finished
// session left unread), and an eased sequence the busy state breathes through.
const TRAY_BREATH_FRAME_COUNT = 16;
// Track the most recently focused window (main or mini-chat) so tray actions
// can target the surface the user was last using, even when the tray menu is
// open and nothing is focused right now.
app.on('browser-window-focus', (_event, browserWindow) => {
if (browserWindow && !browserWindow.isDestroyed()) {
state.lastFocusedWindowId = browserWindow.id;
}
});
// The window the user is "on" for tray routing: the focused one, else the last
// focused that is still alive.
const resolveTraySurface = () => {
const focused = BrowserWindow.getFocusedWindow();
if (focused && !focused.isDestroyed()) return focused;
if (state.lastFocusedWindowId != null) {
const remembered = BrowserWindow.fromId(state.lastFocusedWindowId);
if (remembered && !remembered.isDestroyed()) return remembered;
}
return null;
};
const trayIconAssets = () => {
const dir = path.join(resourceRoot(), 'icons', 'tray');
return {
idleIconPath: path.join(dir, 'trayTemplate-idle.png'),
unseenIconPath: path.join(dir, 'trayTemplate-unseen.png'),
breathIconPaths: Array.from({ length: TRAY_BREATH_FRAME_COUNT }, (_, i) =>
path.join(dir, `trayTemplate-breath-${String(i).padStart(2, '0')}.png`)),
};
};
const setupTray = () => {
if (process.platform !== 'darwin' || state.trayController) return;
const assets = trayIconAssets();
if (!fs.existsSync(assets.idleIconPath)) {
log.warn('[electron] tray icon missing, skipping tray setup', { iconPath: assets.idleIconPath });
return;
}
try {
state.trayController = createTrayController({
...assets,
onAction: (action) => { void dispatchTrayAction(action); },
});
// Seed an empty snapshot so the icon appears immediately; the renderer
// pushes the real state once the sync stores are mounted.
state.trayController.update({ sessions: [], approvals: [] });
} catch (error) {
log.warn('[electron] failed to set up tray', error);
state.trayController = null;
}
};
// Bring the existing main window forward WITHOUT re-navigating it. Only when
// no live window exists (truly closed) do we recreate one — recreation reloads,
// but showing an existing window must not. This mirrors desktop_focus_main_window
// and the notification "open session" path; calling openMainWindow on a live
// window navigates it (full reload), which is the bug we're avoiding here.
const revealMainWindow = async () => {
let target = state.mainWindow;
if (!target || target.isDestroyed()) {
target = await openMainWindow().catch(() => null) || state.mainWindow;
}
if (target && !target.isDestroyed()) {
if (target.isMinimized()) target.restore();
target.show();
target.focus();
}
return target;
};
// Open a session in the main window, creating one first if none is alive. A
// freshly created window can't receive an immediate emit (its renderer hasn't
// mounted its listeners yet), so we queue the session as a pending deep-link —
// the did-finish-load handler flushes it once the window is ready.
const focusMainWindowWithSession = async (sessionId, directory) => {
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
if (state.mainWindow.isMinimized()) state.mainWindow.restore();
state.mainWindow.show();
state.mainWindow.focus();
if (sessionId) {
emitToWindow(state.mainWindow, 'openchamber:open-session', { sessionId, directory: directory || '' });
}
return;
}
if (sessionId) pendingDeepLinks.push({ type: 'session', value: sessionId });
await openMainWindow();
};
const dispatchTrayAction = async (action) => {
if (!action || typeof action !== 'object') return;
if (action.type === 'quit') {
app.quit();
return;
}
// Responding to a permission doesn't need to steal focus — just deliver it.
if (action.type === 'respond-permission') {
const target = (state.mainWindow && !state.mainWindow.isDestroyed())
? state.mainWindow
: await revealMainWindow();
emitToWindow(target, 'openchamber:tray-action', action);
return;
}
// Mini chat opens its own small window; we only need a renderer with context,
// not to surface the main window.
if (action.type === 'new-mini-chat') {
let target = getMenuTargetWindow();
if (!target) target = await revealMainWindow();
dispatchOpenMiniChat(target);
return;
}
// Open a session on the surface the user was last on: if that's a mini-chat,
// switch THAT window to the session in place (no new window); otherwise use
// the main window.
if (action.type === 'focus-session') {
const surface = resolveTraySurface();
if (surface && surface.__ocMiniChat === true && action.sessionId) {
if (surface.isMinimized()) surface.restore();
surface.show();
surface.focus();
emitToWindow(surface, 'openchamber:open-session', {
sessionId: action.sessionId,
directory: action.directory || '',
});
return;
}
await focusMainWindowWithSession(action.sessionId, action.directory || '');
return;
}
const target = await revealMainWindow();
if (!target || target.isDestroyed()) return;
if (action.type === 'new-session') {
emitToWindow(target, 'openchamber:open-draft-session', { directory: '', projectId: '' });
}
// show-main-window: revealing the window above is the whole action.
};
app.on('window-all-closed', () => {
if (process.platform === 'darwin' && !state.quitRequested) {
return;
@@ -4061,16 +4254,23 @@ app.on('open-url', (event, url) => {
app.on('activate', async () => {
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
if (windows.length > 0) {
const visibleWindow = windows.find((window) => window.isVisible());
const targetWindow = visibleWindow || state.mainWindow || windows[0];
if (targetWindow.isMinimized()) targetWindow.restore();
targetWindow.show();
targetWindow.focus();
// Only spawn a main window when there is genuinely nothing to come back to.
if (windows.length === 0) {
await openMainWindow();
return;
}
await openMainWindow();
// Otherwise bring back the surface the user was last on — restoring it if
// minimized — instead of surfacing a hidden window or creating a new one.
// This covers e.g. "only a minimized mini-chat remains": it should un-minimize
// rather than open the main window.
const remembered = resolveTraySurface();
const targetWindow = (remembered && !remembered.isDestroyed())
? remembered
: (windows.find((window) => window.isVisible() && !window.isMinimized()) || windows[0]);
if (targetWindow.isMinimized()) targetWindow.restore();
targetWindow.show();
targetWindow.focus();
});
app.whenReady().then(async () => {
@@ -4091,6 +4291,7 @@ app.whenReady().then(async () => {
if (process.platform === 'darwin') {
Menu.setApplicationMenu(buildMacMenu());
setupTray();
} else {
Menu.setApplicationMenu(buildAutoHiddenMenu());
}
+4
View File
@@ -50,6 +50,10 @@
{
"from": "resources/icons/icon.ico",
"to": "icons/icon.ico"
},
{
"from": "resources/icons/tray",
"to": "icons/tray"
}
],
"afterPack": "scripts/after-pack.cjs",
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"
stroke="#000" stroke-linejoin="round" stroke-linecap="round">
<g stroke-width="2.3">
<path d="M16 2.5 L28.5 9.5 L28.5 23 L16 30 L3.5 23 L3.5 9.5 Z"/>
<path d="M3.5 9.5 L16 16.25 L28.5 9.5"/>
<path d="M16 16.25 L16 30"/>
</g>
<!-- thinner stroke for the diamond on the top face -->
<path stroke-width="1.5" d="M16 6.5 L21.5 9.4 L16 12.3 L10.5 9.4 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

+269
View File
@@ -0,0 +1,269 @@
// macOS menu bar (status bar) controller.
//
// Surfaces a glanceable, always-visible view of OpenChamber's live state:
// 1. an aggregate activity indicator (idle / busy / error+retry) in the icon
// title, rendered as a monochrome template image plus a text counter so it
// adapts to light/dark menu bars (colour can't be shown in template mode);
// 2. pending approvals (permission + question requests) that block agents,
// with inline Allow/Deny actions;
// 3. the list of active sessions with status + branch, click to focus;
// 4. quick actions (new session, show window, quit).
//
// The live state lives in the renderer (Zustand). It is pushed here over the
// existing IPC bridge via the `desktop_tray_update` command; this module owns
// only presentation. Tray clicks call back through `onAction`, which main.mjs
// routes to the renderer (focus-session, respond-permission, …) or handles
// natively (show-main-window, quit).
import { Tray, Menu, nativeImage } from 'electron';
const MAX_SESSIONS = 12;
const MAX_APPROVALS = 10;
const truncate = (value, max) => {
const text = typeof value === 'string' ? value.trim() : '';
if (text.length <= max) return text;
return `${text.slice(0, Math.max(0, max - 1))}`;
};
// Glyph only for sessions that have an actual state; idle sessions get blank
// indentation so the row reads cleanly without an empty circle.
const statusGlyph = (session) => {
if (session.status === 'busy') return '●';
if (session.status === 'retry') return '⟳';
if (session.hasError) return '▲';
if (session.unseen > 0) return '✓';
return '';
};
const sessionLabel = (session) => {
const glyph = statusGlyph(session);
const parts = [glyph || ' ', truncate(session.title || 'Untitled session', 38)];
if (session.branch) parts.push(`${truncate(session.branch, 18)}`);
if (session.unseen > 0) parts.push(`(${session.unseen})`);
return parts.join(' ');
};
const approvalLabel = (approval) => {
const icon = approval.kind === 'permission' ? '⛔' : '❓';
const who = truncate(approval.sessionTitle || 'Session', 24);
const what = truncate(approval.label || (approval.kind === 'permission' ? 'Permission request' : 'Question'), 34);
return `${icon} ${who}${what}`;
};
// Text shown next to the icon — reserved for the two states where a precise
// count is actionable: pending approvals and errors. Busy and unread are
// conveyed by the icon itself (animated / filled faces), so they add no text.
// Glyphs come from the Geometric Shapes block so macOS renders them monochrome
// (not colour emoji) and tints them with the menu bar like the template icon.
const computeTitle = (counts) => {
if (counts.approvals > 0) return `${counts.approvals}`; // decision needed
if (counts.error > 0) return `${counts.error}`; // problem
return '';
};
// Which icon variant to show. Busy work animates a "breathing" fill; unread
// (with nothing active) holds a static filled cube until the state clears;
// otherwise the plain outline.
const computeIconState = (counts) => {
if (counts.busy > 0) return 'busy';
if (counts.unseen > 0) return 'unseen';
return 'idle';
};
const computeTooltip = (counts, sessionCount) => {
if (sessionCount === 0) return 'OpenChamber — no active sessions';
const bits = [];
if (counts.approvals > 0) bits.push(`${counts.approvals} awaiting approval`);
if (counts.error > 0) bits.push(`${counts.error} with errors`);
if (counts.busy > 0) bits.push(`${counts.busy} working`);
if (counts.unseen > 0) bits.push(`${counts.unseen} unread`);
const suffix = bits.length ? ` · ${bits.join(', ')}` : ' · idle';
return `OpenChamber — ${sessionCount} session${sessionCount === 1 ? '' : 's'}${suffix}`;
};
// Frame cadence for the "breathing" busy animation. With the eased frame set
// (denser near the extremes) a slower tick reads as a calm, continuous glow
// rather than a snappy blink.
const ANIM_INTERVAL_MS = 75;
const toTemplateImage = (p) => {
const image = nativeImage.createFromPath(p);
image.setTemplateImage(true);
return image;
};
// idleIconPath: plain outline (calm state). unseenIconPath: statically filled
// (a finished session left unread). breathIconPaths: eased outline→fill frames
// the busy state ping-pongs through.
export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconPaths, onAction }) => {
let tray = null;
let lastTitle = null;
// macOS auto-picks the @2x file next to each path and tints the alpha.
const idleFrame = toTemplateImage(idleIconPath);
const unseenFrame = toTemplateImage(unseenIconPath);
const breathFrames = breathIconPaths.map(toTemplateImage);
let iconState = null;
let animTimer = null;
let animIndex = 0;
let animDir = 1;
const stopAnim = () => {
if (animTimer) {
clearInterval(animTimer);
animTimer = null;
}
};
const startAnim = () => {
if (animTimer || !tray || tray.isDestroyed?.()) return;
animIndex = 0;
animDir = 1;
animTimer = setInterval(() => {
if (!tray || tray.isDestroyed?.()) return;
tray.setImage(breathFrames[animIndex] || idleFrame);
// Ping-pong for a seamless, infinite in-and-out breath.
animIndex += animDir;
if (animIndex >= breathFrames.length - 1) { animIndex = breathFrames.length - 1; animDir = -1; }
else if (animIndex <= 0) { animIndex = 0; animDir = 1; }
}, ANIM_INTERVAL_MS);
};
const applyIconState = (nextState) => {
if (nextState === iconState) return;
iconState = nextState;
if (!tray || tray.isDestroyed?.()) return;
if (nextState === 'busy') {
startAnim();
} else if (nextState === 'unseen') {
stopAnim();
tray.setImage(unseenFrame);
} else {
stopAnim();
tray.setImage(idleFrame);
}
};
const ensureTray = () => {
if (tray && !tray.isDestroyed?.()) return tray;
tray = new Tray(idleFrame);
tray.setIgnoreDoubleClickEvents(true);
return tray;
};
const buildMenu = (snapshot) => {
const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
const approvals = Array.isArray(snapshot.approvals) ? snapshot.approvals : [];
const header = typeof snapshot.instanceName === 'string' && snapshot.instanceName.trim()
? snapshot.instanceName.trim()
: 'OpenChamber';
const template = [
{ label: header, enabled: false },
{ type: 'separator' },
];
if (approvals.length > 0) {
template.push({ label: 'Needs your attention', enabled: false });
const approvalItem = (approval) => {
if (approval.kind === 'permission') {
return {
label: approvalLabel(approval),
submenu: [
{ label: 'Allow once', click: () => onAction({ type: 'respond-permission', sessionId: approval.sessionId, id: approval.id, response: 'once' }) },
{ label: 'Allow always', click: () => onAction({ type: 'respond-permission', sessionId: approval.sessionId, id: approval.id, response: 'always' }) },
{ type: 'separator' },
{ label: 'Deny', click: () => onAction({ type: 'respond-permission', sessionId: approval.sessionId, id: approval.id, response: 'reject' }) },
{ type: 'separator' },
{ label: 'Open in app', click: () => onAction({ type: 'focus-session', sessionId: approval.sessionId }) },
],
};
}
return {
label: approvalLabel(approval),
click: () => onAction({ type: 'focus-session', sessionId: approval.sessionId }),
};
};
for (const approval of approvals.slice(0, MAX_APPROVALS)) {
template.push(approvalItem(approval));
}
const approvalOverflow = approvals.slice(MAX_APPROVALS);
if (approvalOverflow.length > 0) {
template.push({
label: `${approvalOverflow.length} more…`,
submenu: approvalOverflow.map(approvalItem),
});
}
template.push({ type: 'separator' });
}
const sessionItem = (session) => ({
label: sessionLabel(session),
click: () => onAction({ type: 'focus-session', sessionId: session.id }),
});
if (sessions.length > 0) {
template.push({ label: 'Sessions', enabled: false });
for (const session of sessions.slice(0, MAX_SESSIONS)) {
template.push(sessionItem(session));
}
const overflow = sessions.slice(MAX_SESSIONS);
if (overflow.length > 0) {
template.push({
label: `${overflow.length} more…`,
submenu: overflow.map(sessionItem),
});
}
} else {
template.push({ label: 'No active sessions', enabled: false });
}
template.push(
{ type: 'separator' },
{ label: 'New Session', click: () => onAction({ type: 'new-session' }) },
{ label: 'New Mini Chat', click: () => onAction({ type: 'new-mini-chat' }) },
{ label: 'Show OpenChamber', click: () => onAction({ type: 'show-main-window' }) },
{ type: 'separator' },
{ label: 'Quit OpenChamber', click: () => onAction({ type: 'quit' }) },
);
return Menu.buildFromTemplate(template);
};
const update = (rawSnapshot) => {
const snapshot = rawSnapshot && typeof rawSnapshot === 'object' ? rawSnapshot : {};
const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
const approvals = Array.isArray(snapshot.approvals) ? snapshot.approvals : [];
const counts = {
busy: sessions.filter((s) => s.status === 'busy' || s.status === 'retry').length,
error: sessions.filter((s) => s.hasError).length,
approvals: approvals.length,
unseen: sessions.reduce((sum, s) => sum + (Number.isFinite(s.unseen) ? s.unseen : 0), 0),
};
const widget = ensureTray();
const title = computeTitle(counts);
if (title !== lastTitle) {
widget.setTitle(title);
lastTitle = title;
}
applyIconState(computeIconState(counts));
widget.setToolTip(computeTooltip(counts, sessions.length));
widget.setContextMenu(buildMenu(snapshot));
};
const destroy = () => {
stopAnim();
if (tray && !tray.isDestroyed?.()) {
tray.destroy();
}
tray = null;
lastTitle = null;
iconState = null;
};
return { update, destroy };
};