diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs
index 24642f7f..6d8fb43a 100644
--- a/packages/electron/main.mjs
+++ b/packages/electron/main.mjs
@@ -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());
}
diff --git a/packages/electron/package.json b/packages/electron/package.json
index 12bb9a5f..7f119ff1 100644
--- a/packages/electron/package.json
+++ b/packages/electron/package.json
@@ -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",
diff --git a/packages/electron/resources/icons/tray/tray-glyph.svg b/packages/electron/resources/icons/tray/tray-glyph.svg
new file mode 100644
index 00000000..d48fb73c
--- /dev/null
+++ b/packages/electron/resources/icons/tray/tray-glyph.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-00.png b/packages/electron/resources/icons/tray/trayTemplate-breath-00.png
new file mode 100644
index 00000000..d1e8e59c
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-00.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-00@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-00@2x.png
new file mode 100644
index 00000000..425f380a
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-00@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-01.png b/packages/electron/resources/icons/tray/trayTemplate-breath-01.png
new file mode 100644
index 00000000..f506e2b0
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-01.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-01@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-01@2x.png
new file mode 100644
index 00000000..6c6f5759
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-01@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-02.png b/packages/electron/resources/icons/tray/trayTemplate-breath-02.png
new file mode 100644
index 00000000..3610abc3
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-02.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-02@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-02@2x.png
new file mode 100644
index 00000000..b8e071dc
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-02@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-03.png b/packages/electron/resources/icons/tray/trayTemplate-breath-03.png
new file mode 100644
index 00000000..8cc1f341
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-03.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-03@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-03@2x.png
new file mode 100644
index 00000000..b17ef327
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-03@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-04.png b/packages/electron/resources/icons/tray/trayTemplate-breath-04.png
new file mode 100644
index 00000000..b5c48ff9
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-04.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-04@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-04@2x.png
new file mode 100644
index 00000000..b1e9a53e
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-04@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-05.png b/packages/electron/resources/icons/tray/trayTemplate-breath-05.png
new file mode 100644
index 00000000..a83d8515
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-05.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-05@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-05@2x.png
new file mode 100644
index 00000000..7fe0d65f
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-05@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-06.png b/packages/electron/resources/icons/tray/trayTemplate-breath-06.png
new file mode 100644
index 00000000..b71e323c
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-06.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-06@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-06@2x.png
new file mode 100644
index 00000000..948c5894
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-06@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-07.png b/packages/electron/resources/icons/tray/trayTemplate-breath-07.png
new file mode 100644
index 00000000..2fbb0bd8
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-07.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-07@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-07@2x.png
new file mode 100644
index 00000000..f9632017
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-07@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-08.png b/packages/electron/resources/icons/tray/trayTemplate-breath-08.png
new file mode 100644
index 00000000..4885e978
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-08.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-08@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-08@2x.png
new file mode 100644
index 00000000..054d7da3
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-08@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-09.png b/packages/electron/resources/icons/tray/trayTemplate-breath-09.png
new file mode 100644
index 00000000..726a1a81
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-09.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-09@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-09@2x.png
new file mode 100644
index 00000000..1ce1e398
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-09@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-10.png b/packages/electron/resources/icons/tray/trayTemplate-breath-10.png
new file mode 100644
index 00000000..ecbb3f18
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-10.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-10@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-10@2x.png
new file mode 100644
index 00000000..94612108
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-10@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-11.png b/packages/electron/resources/icons/tray/trayTemplate-breath-11.png
new file mode 100644
index 00000000..6b8b94f4
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-11.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-11@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-11@2x.png
new file mode 100644
index 00000000..0ecd6da7
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-11@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-12.png b/packages/electron/resources/icons/tray/trayTemplate-breath-12.png
new file mode 100644
index 00000000..26e7a059
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-12.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-12@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-12@2x.png
new file mode 100644
index 00000000..889b4f3d
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-12@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-13.png b/packages/electron/resources/icons/tray/trayTemplate-breath-13.png
new file mode 100644
index 00000000..f8240e30
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-13.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-13@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-13@2x.png
new file mode 100644
index 00000000..cac2fbc9
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-13@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-14.png b/packages/electron/resources/icons/tray/trayTemplate-breath-14.png
new file mode 100644
index 00000000..d85ccee0
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-14.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-14@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-14@2x.png
new file mode 100644
index 00000000..8dd98420
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-14@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-15.png b/packages/electron/resources/icons/tray/trayTemplate-breath-15.png
new file mode 100644
index 00000000..29c7f7fc
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-15.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-breath-15@2x.png b/packages/electron/resources/icons/tray/trayTemplate-breath-15@2x.png
new file mode 100644
index 00000000..01541e30
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-breath-15@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-idle.png b/packages/electron/resources/icons/tray/trayTemplate-idle.png
new file mode 100644
index 00000000..3f2cdb58
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-idle.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-idle@2x.png b/packages/electron/resources/icons/tray/trayTemplate-idle@2x.png
new file mode 100644
index 00000000..d558046d
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-idle@2x.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-unseen.png b/packages/electron/resources/icons/tray/trayTemplate-unseen.png
new file mode 100644
index 00000000..6c2f588a
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-unseen.png differ
diff --git a/packages/electron/resources/icons/tray/trayTemplate-unseen@2x.png b/packages/electron/resources/icons/tray/trayTemplate-unseen@2x.png
new file mode 100644
index 00000000..d1eee378
Binary files /dev/null and b/packages/electron/resources/icons/tray/trayTemplate-unseen@2x.png differ
diff --git a/packages/electron/tray.mjs b/packages/electron/tray.mjs
new file mode 100644
index 00000000..e37f9114
--- /dev/null
+++ b/packages/electron/tray.mjs
@@ -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 };
+};
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 3ad10d35..583d4b9d 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -10,6 +10,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
// useEventStream removed — replaced by SyncProvider + SyncBridge
import { useMenuActions } from '@/hooks/useMenuActions';
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
+import { useTraySync } from '@/hooks/useTraySync';
import { useRouter } from '@/hooks/useRouter';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
@@ -17,7 +18,7 @@ import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils';
-import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp } from '@/lib/desktop';
+import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
import {
getInjectedBootOutcome,
getBootInjectionStatus,
@@ -29,6 +30,7 @@ import {
} from '@/lib/desktopBoot';
import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery';
import { useSessionUIStore } from '@/sync/session-ui-store';
+import { markSessionViewed } from '@/sync/notification-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
@@ -601,6 +603,38 @@ function App({ apis }: AppProps) {
return () => window.removeEventListener('openchamber:open-session', handler as EventListener);
}, []);
+ // Open a draft Mini Chat window from the native File menu / tray. Uses a
+ // dedicated single-fire event (not the menu-action channel) because draft
+ // mini-chat windows are NOT deduplicated — a double dispatch would open two.
+ React.useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const onOpenMiniChat = () => {
+ const currentDir = useDirectoryStore.getState().currentDirectory;
+ const { activeProjectId, projects } = useProjectsStore.getState();
+ const activeProject = projects.find((p) => p.id === activeProjectId) ?? null;
+ void invokeDesktop('desktop_open_draft_mini_chat_window', {
+ directory: currentDir || activeProject?.path || '',
+ projectId: activeProject?.id ?? null,
+ });
+ };
+ window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat);
+ return () => window.removeEventListener('openchamber:open-mini-chat', onOpenMiniChat);
+ }, []);
+
+ // When the window regains focus, mark the currently-selected session as seen.
+ // Turn-completes that arrive while the app is backgrounded are intentionally
+ // left unseen (see isViewedInCurrentSession); coming back to the window is the
+ // signal that the user has now looked at it, so the marker clears.
+ React.useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const onFocus = () => {
+ const sessionId = useSessionUIStore.getState().currentSessionId;
+ if (sessionId) markSessionViewed(sessionId);
+ };
+ window.addEventListener('focus', onFocus);
+ return () => window.removeEventListener('focus', onFocus);
+ }, []);
+
React.useEffect(() => {
if (typeof window === 'undefined') return;
@@ -672,6 +706,8 @@ function App({ apis }: AppProps) {
useMenuActions(handleToggleMemoryDebug);
+ useTraySync();
+
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
React.useEffect(() => {
diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx
index e7ed2823..991300da 100644
--- a/packages/ui/src/apps/ElectronMiniChatApp.tsx
+++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx
@@ -141,6 +141,25 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
sessionBootstrappedRef.current = true;
}, [config, currentSessionId, sessions, setCurrentSession, sync]);
+ // Switch this mini-chat to another session in place (e.g. picked from the
+ // tray while this window was focused) instead of spawning a new window.
+ React.useEffect(() => {
+ const onOpenSession = (event: Event) => {
+ const detail = (event as CustomEvent<{ sessionId?: string; directory?: string }>).detail;
+ const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
+ if (!sessionId) return;
+ if (useSessionUIStore.getState().currentSessionId === sessionId) return;
+ const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
+ ? detail.directory.trim()
+ : (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory ?? null;
+ void sync.ensureSessionRenderable(sessionId);
+ setCurrentSession(sessionId, directory);
+ sessionBootstrappedRef.current = true;
+ };
+ window.addEventListener('openchamber:open-session', onOpenSession);
+ return () => window.removeEventListener('openchamber:open-session', onOpenSession);
+ }, [sessions, setCurrentSession, sync]);
+
React.useEffect(() => {
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
openNewSessionDraft({
@@ -188,6 +207,29 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
};
}, [projects]);
+ // Dismiss the HTML splash (see mini-chat.html) once the real content is ready,
+ // so the window doesn't flash through white/connecting states. Fades out when
+ // the target session is active (or the draft is open); a grace timer ensures
+ // it never hangs (e.g. an unavailable session renders its own state).
+ const splashDismissedRef = React.useRef(false);
+ React.useEffect(() => {
+ if (splashDismissedRef.current || !isInitialized) return;
+ const dismiss = () => {
+ if (splashDismissedRef.current) return;
+ splashDismissedRef.current = true;
+ const el = typeof document !== 'undefined' ? document.getElementById('initial-loading') : null;
+ if (el) {
+ el.classList.add('fade-out');
+ window.setTimeout(() => el.remove(), 300);
+ }
+ };
+ const ready = config.mode === 'session'
+ ? currentSessionId === config.sessionId
+ : draftOpen;
+ const timer = window.setTimeout(dismiss, ready ? 100 : 1500);
+ return () => window.clearTimeout(timer);
+ }, [isInitialized, config.mode, config.sessionId, currentSessionId, draftOpen]);
+
return null;
};
diff --git a/packages/ui/src/hooks/useTraySync.ts b/packages/ui/src/hooks/useTraySync.ts
new file mode 100644
index 00000000..46788561
--- /dev/null
+++ b/packages/ui/src/hooks/useTraySync.ts
@@ -0,0 +1,375 @@
+import React from 'react';
+import type { Session } from '@opencode-ai/sdk/v2';
+import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive } from '@/lib/desktop';
+import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
+import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
+import { getSyncChildStores, getAllSyncSessions } from '@/sync/sync-refs';
+import { useNotificationStore } from '@/sync/notification-store';
+import { respondToPermission } from '@/sync/session-actions';
+import {
+ useGlobalSessionsStore,
+ ensureGlobalSessionsLoaded,
+ refreshGlobalSessions,
+ resolveGlobalSessionDirectory,
+} from '@/stores/useGlobalSessionsStore';
+import { toast } from '@/components/ui';
+import type { PermissionRequest } from '@/types/permission';
+import type { QuestionRequest } from '@/types/question';
+
+// macOS menu bar bridge. The Electron main process owns the Tray UI; this hook
+// streams a compact snapshot of live session/approval state to it via the
+// `desktop_tray_update` IPC command, and routes tray clicks back into the app.
+//
+// Only meaningful on the macOS desktop shell — main.mjs no-ops the command on
+// other platforms, but we still gate here to avoid pointless work.
+
+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;
+// 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, …).
+const GLOBAL_REFRESH_MS = 45000;
+const MAX_SESSIONS = 20;
+
+type TraySessionStatus = 'idle' | 'busy' | 'retry';
+
+type TraySession = {
+ id: string;
+ title: string;
+ status: TraySessionStatus;
+ branch: string;
+ unseen: number;
+ hasError: boolean;
+ directory: string;
+};
+
+type TrayApproval = {
+ kind: 'permission' | 'question';
+ id: string;
+ sessionId: string;
+ sessionTitle: string;
+ label: string;
+ directory: string;
+};
+
+type TraySnapshot = {
+ sessions: TraySession[];
+ approvals: TrayApproval[];
+ // Active instance label (e.g. "Local OpenChamber" or a remote host name) so
+ // the tray header makes clear which instance/window it reflects.
+ instanceName: string;
+};
+
+// focus-session / new-session are routed natively by the main process through
+// the existing `openchamber:open-session` / `openchamber:open-draft-session`
+// events (handled in App.tsx). Only respond-permission needs handling here.
+type TrayAction =
+ | { type: 'respond-permission'; sessionId: string; id: string; response: 'once' | 'always' | 'reject' };
+
+type DesktopBridgeGlobal = {
+ listen?: (
+ event: string,
+ handler: (evt: { payload?: unknown }) => void
+ ) => Promise<() => void>;
+};
+
+const isMac = (): boolean => {
+ if (typeof window === 'undefined') return false;
+ return (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'darwin';
+};
+
+const permissionLabel = (request: PermissionRequest): string => {
+ const head = typeof request.permission === 'string' ? request.permission : 'Permission';
+ const pattern = Array.isArray(request.patterns) ? request.patterns.find((p) => typeof p === 'string' && p.trim()) : '';
+ return pattern ? `${head}: ${pattern}` : head;
+};
+
+const questionLabel = (request: QuestionRequest): string => {
+ const first = Array.isArray(request.questions) ? request.questions[0] : undefined;
+ return first?.header || first?.question || 'Question';
+};
+
+const updatedAt = (session: Session): number =>
+ session.time?.updated ?? session.time?.created ?? 0;
+
+// Mirrors the header's instance resolution (Header.refreshCurrentInstanceLabel):
+// the local origin shows as "Local OpenChamber"; a remote host shows its
+// configured name. Async because the host config is read over IPC.
+const resolveInstanceName = async (): Promise => {
+ try {
+ if (isDesktopLocalOriginActive()) return 'Local OpenChamber';
+ const localOrigin = (window as unknown as { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__
+ || window.location.origin;
+ const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
+ if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) return 'Local OpenChamber';
+ const cfg = await desktopHostsGet();
+ const match = cfg.hosts.find((host) =>
+ runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false);
+ if (match?.label?.trim()) return redactSensitiveUrl(match.label.trim());
+ return 'Instance';
+ } catch {
+ return '';
+ }
+};
+
+// Live data lives in the directory-scoped sync child stores. Aggregate it once
+// into flat lookups so we can attach it to the global session list by id.
+type LiveData = {
+ statusById: Map;
+ branchByDirectory: Map;
+ approvals: TrayApproval[];
+ titleById: Map;
+};
+
+const collectLiveData = (): LiveData => {
+ const statusById = new Map();
+ const branchByDirectory = new Map();
+ const approvals: TrayApproval[] = [];
+ const titleById = new Map();
+
+ let stores;
+ try {
+ stores = getSyncChildStores();
+ } catch {
+ return { statusById, branchByDirectory, approvals, titleById };
+ }
+
+ for (const [directory, store] of stores.children.entries()) {
+ const state = store.getState();
+ if (state.vcs?.branch) branchByDirectory.set(directory, state.vcs.branch);
+
+ for (const session of state.session) {
+ if (!session?.id) continue;
+ titleById.set(session.id, session.title);
+ const type = state.session_status[session.id]?.type;
+ statusById.set(session.id, type === 'busy' ? 'busy' : type === 'retry' ? 'retry' : 'idle');
+ }
+
+ for (const [sessionId, requests] of Object.entries(state.permission ?? {})) {
+ for (const request of requests ?? []) {
+ if (!request?.id) continue;
+ const sid = request.sessionID || sessionId;
+ approvals.push({ kind: 'permission', id: request.id, sessionId: sid, sessionTitle: '', label: permissionLabel(request), directory });
+ }
+ }
+ for (const [sessionId, requests] of Object.entries(state.question ?? {})) {
+ for (const request of requests ?? []) {
+ if (!request?.id) continue;
+ const sid = request.sessionID || sessionId;
+ approvals.push({ kind: 'question', id: request.id, sessionId: sid, sessionTitle: '', label: questionLabel(request), directory });
+ }
+ }
+ }
+
+ return { statusById, branchByDirectory, approvals, titleById };
+};
+
+const buildSnapshot = (instanceName: string): TraySnapshot => {
+ const live = collectLiveData();
+ const notif = useNotificationStore.getState().index.session;
+
+ // The list source is the GLOBAL store — every project/worktree the backend
+ // knows about, independent of which directories this client has opened. Live
+ // status/unread/branch are merged in by id where we have them (the session's
+ // directory is synced); otherwise the row is shown as idle.
+ const allSessions = useGlobalSessionsStore.getState().activeSessions;
+ const titleById = new Map(live.titleById);
+ const childrenByParent = new Map();
+ for (const session of allSessions) {
+ if (!session?.id) continue;
+ if (session.title) titleById.set(session.id, session.title);
+ if (session.parentID) {
+ const siblings = childrenByParent.get(session.parentID) ?? [];
+ siblings.push(session.id);
+ childrenByParent.set(session.parentID, siblings);
+ }
+ }
+
+ const collectDescendants = (rootId: string): string[] => {
+ const out: string[] = [];
+ const stack = [...(childrenByParent.get(rootId) ?? [])];
+ const seen = new Set();
+ while (stack.length) {
+ const id = stack.pop() as string;
+ if (seen.has(id)) continue;
+ seen.add(id);
+ out.push(id);
+ stack.push(...(childrenByParent.get(id) ?? []));
+ }
+ return out;
+ };
+
+ const rollupStatus = (family: string[]): TraySessionStatus => {
+ const statuses = family.map((id) => live.statusById.get(id) ?? 'idle');
+ if (statuses.includes('busy')) return 'busy';
+ if (statuses.includes('retry')) return 'retry';
+ return 'idle';
+ };
+
+ const sessions: TraySession[] = allSessions
+ .filter((s) => s?.id && !s.parentID) // root rows; sub-session work rolls up
+ .slice()
+ .sort((a, b) => updatedAt(b) - updatedAt(a)) // most recently updated first
+ .slice(0, MAX_SESSIONS)
+ .map((session) => {
+ const family = [session.id, ...collectDescendants(session.id)];
+ const directory = resolveGlobalSessionDirectory(session) ?? '';
+ return {
+ id: session.id,
+ title: session.title || 'Untitled session',
+ status: rollupStatus(family),
+ branch: directory ? (live.branchByDirectory.get(directory) ?? '') : '',
+ unseen: family.reduce((sum, id) => sum + (notif.unseenCount[id] ?? 0), 0),
+ hasError: family.some((id) => notif.unseenHasError[id] ?? false),
+ directory,
+ };
+ });
+
+ const approvals = live.approvals.map((a) => ({ ...a, sessionTitle: titleById.get(a.sessionId) || '' }));
+
+ return { sessions, approvals, instanceName };
+};
+
+export const useTraySync = (): void => {
+ React.useEffect(() => {
+ if (!isMac() || !canUseElectronDesktopIPC()) return;
+
+ let disposed = false;
+ let lastSerialized = '';
+ let flushTimer: number | null = null;
+ // The active instance is fixed per window load (switching hosts re-navigates
+ // the window, remounting this hook). Resolve it once, then re-push.
+ let instanceName = '';
+
+ const flushNow = () => {
+ if (disposed) return;
+ const snapshot = buildSnapshot(instanceName);
+ const serialized = JSON.stringify(snapshot);
+ if (serialized === lastSerialized) return;
+ lastSerialized = serialized;
+ void invokeDesktop('desktop_tray_update', snapshot);
+ };
+
+ void resolveInstanceName().then((name) => {
+ if (disposed) return;
+ instanceName = name;
+ flushNow();
+ });
+
+ // 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.
+ const scheduleFlush = () => {
+ if (disposed || flushTimer !== null) return;
+ flushTimer = window.setTimeout(() => {
+ flushTimer = null;
+ flushNow();
+ }, FLUSH_DEBOUNCE_MS);
+ };
+
+ // Event-driven: subscribe to each directory store so session create/update/
+ // status changes propagate immediately, and to the registry so stores for
+ // newly-opened directories get wired up as they appear.
+ const storeUnsubs = new Map void>();
+
+ const rebindStores = () => {
+ if (disposed) return;
+ let stores;
+ try {
+ stores = getSyncChildStores();
+ } catch {
+ return;
+ }
+ const live = new Set();
+ for (const [directory, store] of stores.children.entries()) {
+ live.add(directory);
+ if (!storeUnsubs.has(directory)) {
+ storeUnsubs.set(directory, store.subscribe(() => scheduleFlush()));
+ }
+ }
+ for (const [directory, unsub] of storeUnsubs) {
+ if (!live.has(directory)) {
+ unsub();
+ storeUnsubs.delete(directory);
+ }
+ }
+ };
+
+ let unsubscribeRegistry: (() => void) | null = null;
+ try {
+ unsubscribeRegistry = getSyncChildStores().subscribeRegistry(() => {
+ rebindStores();
+ scheduleFlush();
+ });
+ } catch {
+ // Sync provider not mounted yet — the fallback poll below recovers.
+ }
+ rebindStores();
+
+ const unsubscribeNotif = useNotificationStore.subscribe(() => scheduleFlush());
+ // The global store drives the session list. It updates instantly via SSE
+ // for the active directory; subscribe so those land in the tray at once.
+ const unsubscribeGlobal = useGlobalSessionsStore.subscribe(() => scheduleFlush());
+
+ // Make the tray self-sufficient: load the full cross-project list now
+ // (independent of the sidebar) and refresh it periodically so sessions from
+ // directories this client never opened still show up and stay current.
+ void ensureGlobalSessionsLoaded(getAllSyncSessions());
+ const refreshInterval = window.setInterval(() => { void refreshGlobalSessions(); }, GLOBAL_REFRESH_MS);
+
+ // Safety net: catches anything the event subscriptions miss (e.g. a store
+ // that existed before the registry subscription was attached).
+ const interval = window.setInterval(() => { rebindStores(); flushNow(); }, POLL_INTERVAL_MS);
+
+ flushNow();
+
+ return () => {
+ disposed = true;
+ if (flushTimer !== null) window.clearTimeout(flushTimer);
+ window.clearInterval(interval);
+ window.clearInterval(refreshInterval);
+ unsubscribeNotif();
+ unsubscribeGlobal();
+ unsubscribeRegistry?.();
+ for (const unsub of storeUnsubs.values()) unsub();
+ storeUnsubs.clear();
+ };
+ }, []);
+
+ React.useEffect(() => {
+ if (!isMac() || typeof window === 'undefined') return;
+ const bridge = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
+ const listen = bridge?.listen;
+ if (typeof listen !== 'function') return;
+
+ const handle = (action: TrayAction) => {
+ switch (action.type) {
+ case 'respond-permission':
+ void respondToPermission(action.sessionId, action.id, action.response).catch(() => {
+ toast.error('Failed to respond to permission request');
+ });
+ break;
+ }
+ };
+
+ let unlisten: null | (() => void | Promise) = null;
+ listen(TRAY_ACTION_EVENT, (evt) => {
+ const action = evt?.payload as TrayAction | undefined;
+ if (!action || typeof action !== 'object' || typeof action.type !== 'string') return;
+ handle(action);
+ })
+ .then((fn) => { unlisten = fn; })
+ .catch(() => { /* ignore */ });
+
+ return () => {
+ try {
+ const result = unlisten?.();
+ if (result instanceof Promise) void result.catch(() => {});
+ } catch {
+ // ignore
+ }
+ };
+ }, []);
+};
diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts
index 2ebe9cc0..62cdeca3 100644
--- a/packages/ui/src/lib/shortcuts.ts
+++ b/packages/ui/src/lib/shortcuts.ts
@@ -219,6 +219,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [
defaultCombo: 'mod+alt+n',
label: 'New Mini Chat window',
description: 'Open a new Mini Chat draft window',
+ customizable: true,
},
{
id: 'submit_message',
diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx
index 1f44bfe3..1aff7111 100644
--- a/packages/ui/src/sync/sync-context.tsx
+++ b/packages/ui/src/sync/sync-context.tsx
@@ -387,9 +387,21 @@ export function setExternallyViewedSession(directory: string, sessionId: string,
externallyViewedSessions.set(key, Date.now() + EXTERNAL_VIEW_TTL_MS)
}
+// The window must actually be focused for the active session to count as
+// "seen": if the app is minimized or in the background, a turn finishing in the
+// currently-selected session should still raise an unseen marker (in the tray
+// and in-app), since the user isn't looking at it.
+function isWindowFocused(): boolean {
+ return typeof document !== "undefined" && document.hasFocus()
+}
+
function isViewedInCurrentSession(directory: string, sessionId?: string): boolean {
if (!sessionId) return false
- if (_activeDirectory && _activeSession && directory === _activeDirectory && sessionId === _activeSession) return true
+ if (
+ _activeDirectory && _activeSession
+ && directory === _activeDirectory && sessionId === _activeSession
+ && isWindowFocused()
+ ) return true
pruneExternallyViewedSessions()
return externallyViewedSessions.has(viewedSessionKey(directory, sessionId))
}
diff --git a/packages/web/mini-chat.html b/packages/web/mini-chat.html
index ee84d145..f73a2358 100644
--- a/packages/web/mini-chat.html
+++ b/packages/web/mini-chat.html
@@ -4,9 +4,95 @@
OpenChamber Mini Chat
+
+
+
+
+
-
+
+
+