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.
270 lines
10 KiB
JavaScript
270 lines
10 KiB
JavaScript
// 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 };
|
|
};
|