From 9cf79a8890ea4d39874c8b9beacc5a1b233c35cf Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 10 Jun 2026 00:20:23 +0300 Subject: [PATCH] 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. --- packages/electron/main.mjs | 245 +++++++++++- packages/electron/package.json | 4 + .../resources/icons/tray/tray-glyph.svg | 10 + .../icons/tray/trayTemplate-breath-00.png | Bin 0 -> 430 bytes .../icons/tray/trayTemplate-breath-00@2x.png | Bin 0 -> 861 bytes .../icons/tray/trayTemplate-breath-01.png | Bin 0 -> 430 bytes .../icons/tray/trayTemplate-breath-01@2x.png | Bin 0 -> 869 bytes .../icons/tray/trayTemplate-breath-02.png | Bin 0 -> 435 bytes .../icons/tray/trayTemplate-breath-02@2x.png | Bin 0 -> 872 bytes .../icons/tray/trayTemplate-breath-03.png | Bin 0 -> 435 bytes .../icons/tray/trayTemplate-breath-03@2x.png | Bin 0 -> 888 bytes .../icons/tray/trayTemplate-breath-04.png | Bin 0 -> 437 bytes .../icons/tray/trayTemplate-breath-04@2x.png | Bin 0 -> 886 bytes .../icons/tray/trayTemplate-breath-05.png | Bin 0 -> 434 bytes .../icons/tray/trayTemplate-breath-05@2x.png | Bin 0 -> 904 bytes .../icons/tray/trayTemplate-breath-06.png | Bin 0 -> 439 bytes .../icons/tray/trayTemplate-breath-06@2x.png | Bin 0 -> 911 bytes .../icons/tray/trayTemplate-breath-07.png | Bin 0 -> 442 bytes .../icons/tray/trayTemplate-breath-07@2x.png | Bin 0 -> 908 bytes .../icons/tray/trayTemplate-breath-08.png | Bin 0 -> 437 bytes .../icons/tray/trayTemplate-breath-08@2x.png | Bin 0 -> 936 bytes .../icons/tray/trayTemplate-breath-09.png | Bin 0 -> 441 bytes .../icons/tray/trayTemplate-breath-09@2x.png | Bin 0 -> 937 bytes .../icons/tray/trayTemplate-breath-10.png | Bin 0 -> 439 bytes .../icons/tray/trayTemplate-breath-10@2x.png | Bin 0 -> 937 bytes .../icons/tray/trayTemplate-breath-11.png | Bin 0 -> 442 bytes .../icons/tray/trayTemplate-breath-11@2x.png | Bin 0 -> 941 bytes .../icons/tray/trayTemplate-breath-12.png | Bin 0 -> 446 bytes .../icons/tray/trayTemplate-breath-12@2x.png | Bin 0 -> 962 bytes .../icons/tray/trayTemplate-breath-13.png | Bin 0 -> 438 bytes .../icons/tray/trayTemplate-breath-13@2x.png | Bin 0 -> 967 bytes .../icons/tray/trayTemplate-breath-14.png | Bin 0 -> 444 bytes .../icons/tray/trayTemplate-breath-14@2x.png | Bin 0 -> 961 bytes .../icons/tray/trayTemplate-breath-15.png | Bin 0 -> 444 bytes .../icons/tray/trayTemplate-breath-15@2x.png | Bin 0 -> 965 bytes .../icons/tray/trayTemplate-idle.png | Bin 0 -> 426 bytes .../icons/tray/trayTemplate-idle@2x.png | Bin 0 -> 794 bytes .../icons/tray/trayTemplate-unseen.png | Bin 0 -> 447 bytes .../icons/tray/trayTemplate-unseen@2x.png | Bin 0 -> 947 bytes packages/electron/tray.mjs | 269 +++++++++++++ packages/ui/src/App.tsx | 38 +- packages/ui/src/apps/ElectronMiniChatApp.tsx | 42 ++ packages/ui/src/hooks/useTraySync.ts | 375 ++++++++++++++++++ packages/ui/src/lib/shortcuts.ts | 1 + packages/ui/src/sync/sync-context.tsx | 14 +- packages/web/mini-chat.html | 88 +++- 46 files changed, 1061 insertions(+), 25 deletions(-) create mode 100644 packages/electron/resources/icons/tray/tray-glyph.svg create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-00.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-00@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-01.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-01@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-02.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-02@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-03.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-03@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-04.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-04@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-05.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-05@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-06.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-06@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-07.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-07@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-08.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-08@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-09.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-09@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-10.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-10@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-11.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-11@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-12.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-12@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-13.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-13@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-14.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-14@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-15.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-breath-15@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-idle.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-idle@2x.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-unseen.png create mode 100644 packages/electron/resources/icons/tray/trayTemplate-unseen@2x.png create mode 100644 packages/electron/tray.mjs create mode 100644 packages/ui/src/hooks/useTraySync.ts 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 0000000000000000000000000000000000000000..d1e8e59c226dba2751997e6a5de8cf98c7e087df GIT binary patch literal 430 zcmV;f0a5;mP)c#Svsj8PomIX;Fj zqaDKsyhu%@(XfgdR?v_8IKoVtl@cH2tUuaXYR@LBVgtAFC0)nT?R{9uq{H}xJ9t+& zs$&&TaYb>l-xZhp5I0l&0K3@7BV0|*YAd61IFJiI#@FyTxq$}VnNhoEJj7#+;CDEZ z++nMN-Zn;c>?*!PJ#LGxAM2uMG#d18E6|skzl@e~3tO1ddD2oeTd7%Y7L=W@Koyh8 zt%POV!!~{?R_Icvk*_J9wT5-PQmn@_oMH_(IvF%$9B*S-OFZ6A+|$LZ2Xl!RGf_+Y Y0fw+xj)D_o0RR9107*qoM6N<$f^|o`zW@LL literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..425f380ae038c010738e24c852578d144ad5d751 GIT binary patch literal 861 zcmV-j1ETziP)JVQ>-R zqK%u#2!h%qxhe<*2?-Y#NGlgf47wQ<3JEk&6jX2_N;DwkV42zIjN{^+$K1K!`^K4X z-b^k!aNvID{LlaV@16JFy?1!XtfK~w2Kr-s7~?f?wr!hm78BuTBevkS3P%zlxgQD4 zyju^x!QG_!9ef>rCyCz;Y|nfM>BNV)Vd_c@;iUw(0*5lp-}oGl)*++=TX8vSavpC5 zrh#|E8xeaQr?Z;humg+Q6|)W}GSc6Jv=L$la3RTGh*(E5nJXE`uUJ1DAASW+~O19&Phui#kZMzIge6OQE( z8x72{s1Y0og0tiwi=LfTf_7&!xQN~)-+`Nv`vYsHncSMdOhirk0qza%wAbz`lJ_%E zDXKrg448btuPNTV#Yui~#I6PAa@3U8Um<29dN^OM?+vniru*?^!qKhxJmf2T9Is`Za}reg zX2tWAHoK0G!>?z#lTlN}RVnC^DrM8yFL)F0DE6lnScY^j#qo6?menX^S#X{(d5pasy(_KN@pVs-Rc!(3u07BK)1z3Czf9d) z2J!q%X40qmfIo~6@c1kQEx~q^=cKj|`n0|Rox$5P5cG!Tjd;#iAzEJ&k1MW9xuCV0 zk1DlZ7E5~qdo^F9hmzli728L8C_AX$7o*8m;i#!AP~}H0&_5hX+l+G+Le53*Kd}Zr nQXECbV%(UvVu5Utnzo_)F#}HLY=#!500000NkvXXu0mjf_{*7Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f506e2b063d449b8362ca7d29b1ac896c5374609 GIT binary patch literal 430 zcmV;f0a5;mP)=(X2Tllc#1M^V-I)Jtdw{!-^$U}Q+qs74Xe0@&2$}4x3#d6NiX9IuH#+9 zsDVX1!8zQ+cg5r0$7qVDv4tJQ2Fs~gY-cnW4(EaQ@ip8}Zn#NzY}9FuyNV5s;UFAK zZns@Qe+Q!mw(!0w?ued0)@jjfbiu!!z(8tF7|r7fs+iP$(o!_l)XcXE&iH!*HQZ5b zW+5EJEquUl#R;A5Hu8DJTI*QB8@$Fd9O4B=x*4=$0&injPdw2{+}Fdb4-XPQ%0z8( Y1hRBkqK%hBo&W#<07*qoM6N<$f~eWWT>t<8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6c6f5759cf106af077a11c314012fbe620cd78ef GIT binary patch literal 869 zcmV-r1DgDaP)*kp`5eaBvC&DMBO-ElgZUVN*o37_>zQ zK^r$gA_TgS%2h!iM2fhuKwPy*Xwc1|P)4AFsE|PmQKJDthsxAOXB-#rJm${*y&3)e z=FQ}y0|)Lqf4=8?-|yXf?)~wQNkiN6Ea!*>-crkUJ|h#8NZbOT-$*lDeuRUjW^ zK_#jFBxZw&5B!Q^<;_q0^TT!}VlG5ZDg6~>#-nzVY)S(1<$VILL|kV842AC!-VePL zwxNhAe~LBuA;Y|$j5pnnP(OCzW(G2d7b3n3+cBK@hr`wtF-tY?OC$Ii8(SgNr&v)- zBRSof$D+uR$m_<(q1|D73i~SZzEf(`FmBjfV69GoFb-2 z(R}|lxwQ=7*$0`)%bE}PLyC>>u@(p|#Cy1$VYPhFXX-1^8NBrXgw|_r#52AMG4&T}EaeG&srec`m^>fCzsd8M;+w2rHJv+Wm=YSWUOj`3|Ss>#&+hFJK*) zlCL#Rw1@?q#w5NeF84MrMtlPs_@28u6U`!=a2R`dfF;~gto&xqdT?K{gzb{9=^D2& zgCYD1hm!ltj;aZ|MYv5t#Iqdx!k1UjQRWHg5XtYJ*+Nh_&Yi)OA|aMHmO zsNovA@hI%Z6}-c)VtZY!Mm~#q#b3Lkcm^-<7{Bop=UN$*F@jeytS266R@~9XtOIuv d&t{?{@dw3XR?XO8^FIIp002ovPDHLkV1jsZ!B7AI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b8e071dc376084b37a6c6a1b91eee046dfd78feb GIT binary patch literal 872 zcmV-u1DE`XP)yCN&2-lM4!}uU#ILTiN?!4<^qzgOnn~6{25SAspCHO4k{DC(xyAC5AcnW8;Cf{OH za2j|zq7k{rFqn<`3D2XmT{Ek3JQMvnL>pmtD^4f%(~;{)Zsvzf;{?{WV&q=z!#|10 zRlJScgR>q3N$stOtC1Tp{Q<|&x+iI;GB&yC3-8&j79GzY)=UJ>-{y>1+Oav&PVMWo{Kma zx%0s(zlw)(G}FA2TyLMw(35xRL1HM%kOj#?n*Rz6w||>8MhygW|}iH zRK^p==bthzVpqh)tTqs1sL|=>gZLceRSc9Fo zoa8S@ZcRI8X#EML7{MosuW4W8cC>4xMB6KGg5gQ7+d>_NYQ_JCro%05Xe_yY#l*dG z-B=bqntG>(1{I4b#WdfK_c6alBlAPEKYECsgH?vkC|2iB!*M5`3G(ZhGXo>D6?4=; zXqJ8Z6q~s-(+NDJd4vB8Uc{Xyu1V=OVp~?5i)Zm;rq%LBpUICv-(bssFtkDQLOkQ6 z5R)Gg`|(JHp;el-^u4u#X5I)oknsj#X%FFKvf)cgZ^+R yu>q$ljGT(vO+iDk{~3wcY5aVDQ_-oWW$0hP5=rY3WKUoK0000_CDX?bXy5)ne;5) zVgxVtjP}sLeVoEHJ}Wl&D$Yebfsgot8|aUw0mtme56oq6rf?0DS*xOsyVxq}4m`#! z+|2L%3~R~#W72M-GCpCoB|Z>cFV*7uXf1&htO2E1#7aS0rPh)(F&^gq$aHp- zZMMI}9(?d%_Wk+3?`(G7ya`vC^wfaSz;LWT!E6nfUCU-nU@mkbY%4Bna4;iM>cNOv zb{W72xR@26#(SaDnf+qK4lDZRY0!<2yXG6e71`KmN%; z&f`s77crZ0B=fxydOmDN%%8SC?8Ki1vCnb)Vnmww5;IxSEcW5%h}nSA@J-=;3}qNY zVVjDW(Z~@P`vSAVpNX2yc7*m6JNN#sf8eL3lea4KuL|4gh&d5Cl?=BbGZ(d6WP27+9`A3lDdL&|a4LK!@l5E+ zu$_vS>Q8Y$#tO`H*?M~?LQmpdTqr;e;hu=^!!Df3{Aa?}7cqBf?Mu_rp~>rYB=oq{ zVegIPyfe>6k=r7#AFqY>hwT=8*e35&=|?^gHI_`M(gy=9-|18oy)nb+m)gVMMeLZ= zEOX4Zgf6#h{)p%B1dfKSDK*JS>N(XlaWrzaN!`7pTyvR(D)|PFVGCZx9k?EE zwb{U4j4Tnq>w9HHI(0j6HnX1%+oo>Fu=H;zml+(8PS!_ZdqtXsx|XhQ-b|(!Sa&6L z5UN%FC)7!Typ6#ci3|qj;iw^Mj<+K;Dc#No zf^jdNmPU`=xb_N&tj3G@IxwrcUr58;WG8_8v<~>cNsVvFrI%zmM&!OhJS)98u4vF_ z;V009H2HNRv?U7H`V?Z}N8*^Y%aYIoT1S;yAB&aTfOoV$qervPpP7GgR72T*+*Bu# zQr1W}c{!+;I%0+X;ZVsVIM#;9vGDyT(!k5oC^8f4#-bT3B)w|7hW-VI^+E=%3GBT9 O0000yD6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b5c48ff9613ed619f92f80119920ebeecb909409 GIT binary patch literal 437 zcmV;m0ZRUfP)*}aUpSn5&i9;q?>Y3x zsqnush#7njXW~0HDwxJ6?BZ6q8{c$ddg3zPV;_%k7LUTyxQ8Fuz?IJGP{Rt=u!9Tu zhL_k1mlHdNO)Mp+)+$&>6LY9y5)E8Su}bVl&Z>!RCiibgb-cwzd`#7`RC^cJ(&;&D z<5HfX-rCWNR5*h>%ENxeZJbZ?b$rg1@5eWnTqm^SC!S*wH*gm>b5_MuJjoT?(Cr%= zc!&x73Mb+_Byv#?V-(8*pc5966KwXg9CtIA0H#sWrr33Or{Zz9-?J>D&NppV#q fGU@Oj9UX~3Gc#9i@d7PB00000NkvXXu0mjfiod|g literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b1e9a53e47310b5a9e5002e2cf091affa7929719 GIT binary patch literal 886 zcmV-+1Bv{JP)vDrxR}CBu3`GAE%+$cyvF*fZ%tkvMzE!u)7*2%bZ8$J1 zZhd$cQ%UhjycKOS@lOS|ZzY5@@e;<(e-bBfUxK>_A7_|9aR6)U5YmMwaXu^ZJst~8 z15Zb5gl`ANvYems9J)Id^B}&;NPi8|Mu^>mvq}7H__~t8{FHHggGbvDau<%`pM>NZ z4&j!-?8Imido9|v@Qs?AZ9RAeS2AIrV&h^$n)njaNzx3C;EupNfRhoMz}r}#aI6pC zL|{%vj^H>FoF)Hs)NED>I+%^%2W(B^UAP*t-?3$p!EFi5Y~-X1@Ydi?YaOg2c`*Z( zq6QMo9&;b~CB@8Jlf>7A?^0mSM^0%26=G(ic8l#z0Qvj=8ruWc41$XhyMSk-T?pUB zz?84zA$*Z>UQPOYmlE_0-of7)$qC#a_#O=5auUBBzMjC`t94zP#D^GcN6>D?Y}*#e z>B>A4MK(lUFZM_44d0!3zasA#9trNAKjH<&V)?3KrZg3c>TN{u689WyMisI{J-8GgisE)wB)$3x)VWA#rFJM*si- M07*qoM6N<$f^;>aOaK4? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a83d8515866b29b80786004b90552d6d910f252d GIT binary patch literal 434 zcmV;j0ZsmiP)K~y-6ozuTd)NvRG@Rvu65!7;H zbIhT@`J8I1u_z=cBC^4V21SXKmX>f3jvz9)Yxus=yRY7z^@cY*JfG)zzn|~t`*|3Q zbK!sEH0JR$oKNn=s9*-)v5Pz5ZgMlJ>5HrQgncaG5*~!3SimoA;9A)o>Uf3M*ufR- z;TgV!FH<{?O}t1?y;E=tEj&RL(`e#W%qoeSIjg3&mEMDiwy=g8zQuJsZfjvZlU~L) zCh)l|)K*;N6c#ay_i4C^8~BKKxQhlRW7>vOy7323u@bR+xSg{q9%DJ5*o9udv5ALq zZHJS|9T7QhqB4G9y(8|4fe-7X=oA{k@IUkpr>9G(fs2YuzOMhImDH@Hr%^6A<8TGO zVh&??9*!yYY!kl~D|EKs$mj7$@oHD`7H cXQE^A7Y{aBDZll|R{#J207*qoM6N<$f`5w0p#T5? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7fe0d65f746045806895d4c1c530b54f3e0469b3 GIT binary patch literal 904 zcmV;319$w1P)r>m=#nHMM6+e6cG^xzSvVj_E1_d7!>)C4O0rK4>4qsAcPrdF}J3Ny*|#KJ>Te@ zGiS_){;=RXYp?xZ|NVXYvi9LDL!$#6-RO_!k1^Q+&bF}%`!E%@FMMYl%ds;PQrgal zS#T`DyBN=skKygGvCKanu}c<0NDnsQxan_V5Z7gK*I;`Qa{@2nyiN#d;6aQONq)fU zi0Q^aSaURyJBv_*9GaXLE0T+*Wf_bejt2}Y%o6+9N*%$R)k!EZTKf6 zIf++sVZ^M$-mLAFu#@52tGU{G@htu*gnfdmPbH)WUtuEaG>NUaEMjiN?r0muJ6M`= zEDhgi#O#hff@5oNR{Rr@v!_nbmSO}4u`+9K;Lm9L4J%GDxD^pI6@Bszcx7AHW$lZ?cQj%~qEChX8ZlFmdj{5J0p<7p26seUPY@i5w!?Td z>~Q#wL`?N5Zo!uY=ZWlj-RT59hWGJzK{ANzBfb~U;aJvwEPTBYv&_tWX$&7?V=ICN za4l|+&gDekjw_>IA6^XW3*Y5<&-5LA5})C&;2vm4P-UxwEFbA;Bwd_w^hw*pUq$R5 zJWz1f3Oc7wSq(<;q_l*e!41;<*nqXt9t6+h;;i5IcpXD?%rj>}6^8JLwCQceRUzQD zngP5i&AFD)s-Oyk_(EFJPvN5QAIBT`NSlRPyMky=?%$=-176U^TglP($(vz(mglz6 z2|*pj|AJ13vs%zp_Pj|thZYLUBFBu}xu9X`Fom13123C?G|U%^gL6mZ5IKkI1Rau; ze-Mhhq+?+aPviV~2ssyzOXuO*EbpgMF?Xm=5d4hwxK%n3OR|SFZx(Yw1NUN=G^h*0 zKO|KW_v6P_$Y#3&t=BxY^SWkZ=6@L96@BM@6=L>F;vU>rBj_G8OO=jZ7AstWw@fd( z#-&?B_4{+p8%I@?ZNsIV6jEZjG$+bJy|i7<&>s#J?#BKaA^W54KS8%ts7-`z*4ipu eFm&nAG4wBE%R9Y(wRQIZ0000`E@TB6~Jh=b6c zi#4`HM59YI)shh9V6;>eq%NXaVALQDIRqL+1?4C6Y+b5_TLm~2*bZGYn? z?qaBv8%pjklf62s;wzR*aa(j9to@?=v5%tnAGE#EY?Y0sa2(S(uXCrh)J#V+RV_GV zX9ZRj`*m6|(KCt16a)BEu|kJCwcLmMim82rmx}qB&1*ctiB1Mp4B&MPTZsqS758*8 h>%q0eH`CFc_ybb;S(oPFC@25`002ovPDHLkV1nJp$bSF; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..948c5894e390a56dc792ed3245632523d2b92507 GIT binary patch literal 911 zcmV;A191F_P)*R7p7wDir9i<88&7{%CRvx z^Nz)M3*%YwF}xmQEQ^l^ckz4}>BUp{P3M!?jH@!=y*iq#Cj5X-#*39+zx)A*(MB8C@74~HFdm`4#Zf2y=_zpMC#mJ?27k_6W z$M7=F3C;>^&*ol=aV%onO@D2Dcm_ubW1r%R*^Kn!D@-X3 zNyJ8jvn6tb#=6j~^e3WbZ=IpF#U1R$@@&3^KVt4z49s$K1HqY!oO}UZ9@=@WwRIwo z7o^J6V8&Tx`UO8EeR+$r`9%>s6rBB$Q(>^i%v98#fz=t|_afn2+!DOr5I7if2QU=l zK*SCPr}`;wz?X&Q(d>Hl$qYS#ckovsvKdzgzYovia5jH9Vtv86(#&;f3?E{6E{2A1 z4Q`L*@(-Dc_>t5Byc)3=@x{N;?0)CXFn)sT zuF!;`M)AL)li{>BG?iUHD)qTR091$Yd1n|>I_ zqc%F9&^~E*elHyNU}cyZHob#)gEOTSenV(hbw87~p?!6R;3qtU8}Utz*u6D5v!NyR z)>XT4cErEO&A1Cc&PA;52((w)RI6P(?`wv$_`^Dn*^L~9IPoEITNo}4tu(V$Y4osI z;e4r;$eXTl>8H&*%F*oaN$E&+QIkeWEW;+9PoQpWvOxcEsBoLqfR#pe$J~E{HlCJx l?uoc=&ziA7=+V$N^bg!-J{t487ybYM002ovPDHLkV1hC)y6*r0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2fbb0bd86bf64801aced7763eec1cc8779cd31fb GIT binary patch literal 442 zcmV;r0Y(0aP)uurP=y{sBVB5)dsVot+>AENnz8 zL9385DJ*Oxq?AAq#6qG)AgMx%fY?YrK(P@lR#8GgNnm3!$GE$!Z=(ke%*?yz-23j_ zp+8QB|Bd6A#BMm5vm>L934B5mcf)4RCK6MLt9XMwJjQuE3eRE+KkyFMy0b$AOIXGS zT*5cJ#CG^Pu_M^Pt7sbShMQ>N8HR8jJGhl(^{jXDZ78v=Xb&se!dr~tW3rAU+j`hY zr5ErXm-CBT?G?SiBCcQtU-1PGa53WB_>Av(l722m>zGdLr!OnGsWkTv4sZ(3F|Uk6 z2mW@AyO_m|>}y%i_A=>JQ8$|Y^q^T4*mtmwigu&Z82AV6Kr|gja~Q?~##L8ZPs~Cz zbKL!9QT+3Nc k)cTm!@G$G?RMZo{05d38&HJpI&j0`b07*qoM6N<$g3|88uK)l5 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f96320173a7399e9ca60c29d09f6be62f65d65dd GIT binary patch literal 908 zcmV;719SX|P)*|cN zn-~4zz?tXc|NPH8^FHUC;VeU~3mkO}#QXzHc7d~FEW;3{Vhn|C&aoKVGa==%Jz{1Z z3-AWUv*KfTEyh@8ACK4tvmvB`r|_HRC$SUPW^q?zYboYWyomFiX6eQIXDaciKyAA611VTf&=K!{52d8-!E7)!{U}i%v9v$4Y)tJ^I99KNS-JK z6`}^RnB}H_;76r5Z(in~7q+7jb2xGe3{;4jirOt$nFSm#C47ZjBd!qyN5VIZM`H|! z?MTEFU&W31ti(Bz%~zgE&@F`*Y&uxT{NU7xkBne@Ek76^1hY!v!Tn@5KEw%GLv==9&!+1a5#tyuNU%Mn^eh9hK^ust2wbA^9 z4obW8yP>!TcjJ9&yXe(9&XxA7_ky#i`>}MGJ6I(Me#FDLN$SL9vU@9XW4%)T1mlycAbD#XbziGz5kLeTwMk1AcgEEbrL zS2Z8H#_?tL`BTl;=}GBIbxAjcl(ATP`^pFP^4MXH{%|O8JN8uw*%!Y51a&+uow+As iUY{{zj?kk^+t9!F%sz`1W#U}`0000P5jhH1QBMT*Er9XIN+Q^_^INn>ZWt4gA0w?&E4W({RXk{LU_B(4XZF<*bHB`J_gP*Y+A4 zn8ILTM}w*D5ZTR98OvBG6r{?qTJA} zbxXgr-qI-g@G8-V5iHvJg^pBNK7j{XuXYyi@D{J}2TyRSN}!Bhyoq2ld2hSss$Kj$ fzm@z>9PNs~scBbFZZV4J00000NkvXXu0mjf+i=WH literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..054d7da33bff37799af4c35b6ccbfd9464c37cc1 GIT binary patch literal 936 zcmV;Z16TZsP)w7cqm3SY19Z}z-=EI}{gQ=BT1Y{A^{@4@ppmdzgvTTl4hXk=ZgNtOJi z@d)a}B0LX6{wB!+!i_^sH6B_&{(*n1r2A2)qA9qksFO~Xuotie-er-SR$QQE1ci0p&JiM9pjQr%vVw~x4)Sn_)R)QZ&xq- zIoZ8UVp^dh9mwmYMHOwy54aQe;paSHH1ZvoA3}Z!+f=C=T&La>wZbgCiB;;C#ozHH zZk2lJwlLZyaX>ol`!icV9yhW(?C52&z*MO>%A2m5bk8b2Z_{|49+Iw9S9MZI8MCDW zbhJMjim}-R`op2XBJ6G=WOvM66jY_&crZqv`u+Z*Vv-IWL;nF3ge|oAFH6?|0000< KMNUMnLSTX&Q@WP` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..726a1a81b64a62676deb4d53e5ae5d250460c6bc GIT binary patch literal 441 zcmV;q0Y?6bP)+-=-Bb9KsbiV8kf_G# zNi5@ZoG!IHn!|Hkz-??`9ak|D@+GX|EAHZAICC(d6Fd3tSEwnLdpT#7?CBx4yLf*I zwXlu*A+QV7#ZK`1NKL}$k7VXwpN@64Io z=b6ce{;*-Q_PYG{KIg2p_Td6UqXr*M^v3)%4A1bY}T<5+cF~Mu`P6F z9CNV=gIVyi*cjt%CLavlxidke15e{Gttas{7G-+ZVrxm~BwoVBbr5M_B@UK?{D6Bx zr-@ZDnjyOjdrLOIVh!fBOXfy=TXK3NoHm2(3JhfH10ickeS@jY&z4w0+yA(Ag0-!pG+DS@<7{m>pGw)|GbfBbH?A4V;Lz-?4at%`Fa{k+8`V@RIPI$68nA z>?Sz+`AKPG*7o!NS4$c}~1!LTXNTR~9L)_hSw2mvRtk*FBlt0c?>H=9Dm1 zLInmQ=uLP?dNe(Fy6xMViedE zvbMX%^C47VaGZHtsDn_g_@B^ZxS$D*Wb+3xe+J{uj~HWO=Xq!^PD?3&5#E&Y$tU=; zCL-NZ30i8jM>-v`(RxDrrRuyT7&mY?-j&M5`P>>hrGmOKd=_NBkea#uRfOOdJc3)K z%3OA~yuxN8G^B$3wzR3DE!l%Ra4&w!4aRfchuiP~ehyiW)H=MT^)5CZ7T^^;i_1dx z8y=Tx;m#@esKj`e#Qu=yjn;apQ`}Y3t@W~4;8MJ$wdoqtdLjHy>veiqx>8+UCy_E1 zV!PH8s2bbN(hr9M%cV}ROk`iI{U>PR8R^jt#k@J8#Vlctn#$0>YO*bEU6{!?00000 LNkvXXu0mjfrSQFp literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ecbb3f185a88258af0ae5204e27f11c822838208 GIT binary patch literal 439 zcmV;o0Z9IdP)htT*eeoHdf$igu@?Hwm1?r&JwIwHIMCogT+~ zoWZ+j9f;8{Wth`Ag$Z2D{&p&l;Hh$ROL&0u(e4;k_=zWYj!|64WX>9Rgn4Xt=>9UQ zu#H(|m{$|KtXx1>>}FJ96Cc~wyJF9WwO_OwHPH7D+P-N1Fq**;<(5vPr^cWsIn&Y1 zbS4~9zQA{U#x0EDSvZCp%FL=S)L+-~0PZPoXAP@Z!LssZ7I3uApc5w|ZiUNFL5ls+TLJ$!VXhcE85CoMHg@IDg*kEaCN?}~&*0SKQ*7kXSgoYy9PZaofCK#^V&hAhIKen3UUB< zhE5&##i)nuHta3g{Duvf(<+%8@I}e#v2a=svTJZKTR#}GTDF;AN&}jTL(7buM)A3aP>q)8rY4Y%xDoxoxYky$r1e;qLIwN6|C*Wn_JCD^} z<>cX#QbDRK(^+Hmf*+TDd2_S%xgk3qI!D5$KvxBsk%&D8>own*+@KdLL$47I`eW@V z9*A)?Wc{I2d=)ogSIOt8Y`%UnLNDS&qjw?CJJ*MP2cE=0wmuNDj?lT**t|3--K{Ot zA=Dk8H->TfqW9pcuYBPmJ5o1j3JP+-aD)2n%3G=*^Pu`cB z`*z@xAhN<}k8~yQkoz65~A*zmr}Qd84%jPe^^wj6D_$T#8pTH(f*cHv8PG`8ho-J*h5lCy_Fi zV29=ts2V%W(jN{5?!f*EBKu?QKS3RjOJChk%S3d}6P@r*+`+F8_PZx%a#8ayU8@ z|BVxv!1u&N-j0lRjA9)dxSrU^+h{l~QO6tXVispHlQ@lA_<x;sst~7?Zdb@<}|$6YMnU{xB-B zg9j1b$lEx!D)^N~CAJhhEAvVmdRRxrL8C5o{DZb5n%|7(6}K^kA+)tHXbWd5nt3>u z;wQ{0b~c7Se8U%<59cjaXTwm#**SF_wW#BT7GmB2JkY5jpPGW kV>o=26XQIF27l$%fZJ;o|D*ylh07*qoM6N<$f=Q;nGynhq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0ecd6da71429449a72050361532f06360b479dfc GIT binary patch literal 941 zcmV;e15*5nP)wG<$p^xA=424*#54F^b0xmSHJRO2_^@R27hc8mb`YuIAsj3v`40Dl zO&yQKsE6zh>?-;EjHT#kmCOy;S_=AW1g!_z#puiQeIcu5i}|S(V+R&agvb?Gi+{2p z{aA?$!)6h-XSz3H^oMM_;oa7S=kP~~*k|Y-i%2K7U@&tU!Wvu_HrHcQ=uY8X%+6xW z4%w-&*%Uq^#+ry(#2<{DomGUEmsYS3^D})7e}?WD=8dtqd12ECpS%IjkJx#xJ=aCVW+jc`}4_Q~(Ty11u8VGW$stBD| z#SDLAJvQTBERg!gol<{UFYTO_cpp!N-)fw|<#-EEWH}o6CVY?K%BsYp5h`#3>!ix` zNIRxO>MuR`054%h*k2MPpVr)&_u(U{IgW|(2o?BEs>EjLCGD2pkEM7}>Ot@VF3tRY zkScQE4D*~Zp#le_3S5T=Fe?bWS+RhRv3r8?CrqfoZt3o`Ac$QM^8O&QRqFH;M#~3X zB-|n$GX>U$tR*sMOQ^u$DD#%k4nl3k|AfZFIZddM&3iCs67$Z993yh)eQ1|-0#BC? z#h39lK9z2J?Z8F2AGa93Bb|)gXs*!yAhIqP*QCbxx^!Hee-=bKaW`Izm<5^7rOVv@ zDnf7=kKtyiI^|#sD}2U6O*)WQNQ){C+?}`$_u>28VYJ}ea4R0hk0HAlkK#qClH;LU zx}rRZ86o>6o^O;odP^AXBe4&!XufEz3c@=|wr%}bEHDG_X#R{I#JAb=&XRseI^Sls zlSmnJu~Bmcs>ViB^oK)%yRf%{$llQXC#d6D>8Bfvd3{WeDME)f9Yg;D2?Hm$BXEzn P00000NkvXXu0mjfq6NM7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..26e7a0599d08f119f2a36050e2cee5a31b35b61a GIT binary patch literal 446 zcmV;v0YUzWP)zt56gT+3b)~ zv_#a8AtE~ z_c5P%6z(AA@GzQsyWvMPF@*t~!V1o$S#R={ybXlgjCO6JPguZFd`;KEbel=kGwFd$ zeGFfswLM0E@Evzh!v&nf4ctuUA-u$ntl$|=N4sX!!f(97TO7w#Tnu?Pp5uwK@O9`k zYGGAb^b1@{?HZOV_?1R2EMuuHuf&FjwOO0;0m&R8@P zuq}^^xU1~!JPs?D-N2!6-r*q5DkpLeCox;qaStYy+nH1TSMTv!IfDn-*Y%@2P{VW# oo5^ccEi87)qO(2*xn1KjE z1@;gX1(AV429XdEAtpTtQ4d1UU41AFilnp{93#7smR*F2GNa7YGffYBeVjetd^6*m z&P+b^hYkCzy)OT~zrEL9`*4Dx)&?JS^hWNiwmEbr z9bH(DV_EQ{SR3tVCO;OsU6Vni1NY;g;VbbDF3R*Sz{@3_Kkyi4wu49wOR={UASqa6y_4$afniHGr9iP&41GZv8!ypKj^G>8p2Cv+~w))+g2b(od; zm=&@kp|dq?gpUp3v+&=Dm>pGwR+nb*Ip$~MHT)i9KV#k)lbaVhLt&FA;Q8S@kF~nW z$vq{df>dv&vqW=)4@f6(dNw{iWCNkIH*5;@R*)Hr*dwq!Q~0xFupQTiUPn0CA7lG) zN3?w*+aEf`r&xgZN95n3Xg~WX+iQn!!z@jcvZ?{V`4l)1qP%eI|sL*TiPEhaI2Jq;1Qgj z*?l9e$e!bjbKHaq?7`F0K3a^kg1}Q16L?c9M<#@c5-QM-Po=%J7$=APP!Rc0%JdUP z%NJb)+$Qa@0vkit5}AKXs6bA>~UW!s&R*(6;>UloewJ@g%Jmoo6S%vlc}#g!E{W1%Kp$g8Bwct%G28r+1hbA!>G zZ1#ZNy3L?8=>_0&r4@jS` k5&im@7E^?2+H?*51KUd}ZIRzmBme*a07*qoM6N<$f@Y7k;{X5v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f8240e30aab34b49a2bc2e0042c2acc19b6e40df GIT binary patch literal 438 zcmV;n0ZIOeP)6Zhwo_}IPm@5d+xdScexymli`2k z1Saq+oJj20=*B31U>#%OdSau|bi^b1f3Se%bbO2l$(1mV_lkudK&w#$JBnq_ z<9=e3*lMG1Yt+CN)|%dJapYkg7tKZk==+D-zSJBrs^ApHaT#4547#EjPfZ1SlC0rb zzVG0I;<7hzKAKO88@h!{c#a$R(yrqmX8gU{m57%VH}eW-I{tJEt|)G*9xfzyC_3$; gk$;%?WF|TkdozPqb0NvItN;K207*qoM6N<$f*(}JegFUf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cac2fbc9e0c55962689e8ba3e455918181a5412d GIT binary patch literal 967 zcmV;&133JNP)jwCc86`ou?Sl-A?2|p zIMa@Kcnv4B&+mtuU5>n0=Q0=$L4 zvmi&Y4rc_X8#}YP=VKfV+fI$$)`d0rtt9LNTs)SLPV7J}a~j4*EC|k3*dB8y@G9nK zG3JNuL~yo;kBG4`Vixghk+ZXvptYr6@D-M1^A-FNbH8Bm7(Z@taO&ZcAHYi@cAjf( zs~``RNQJ20jI%=H13x6~yjj`&tgsyl&OrDS=xrgU9=S(gRYo{ga_GYi!Rw3w2V?F4 z?vHUGYzKo=yo#&wQ7PtM+4HLL1idc3|DjTlZMZV{U3e0M+5BMGx`K0=k>^qkpJGER zL8rA+CViBfrETAhWzzX^vvj_^EA5;E4Tu;N`-SSUM%qrOBH|bB;!w7P=UQtJzEySijVkM2bWL-QysI=nEM2M2ZKsej7GbOA6=*fKnxQ`&3fv;Kq-7y{V(vdd p6^~1=t`^VLF+FAo9okfe{sB}~DR-9MJ(>Uj002ovPDHLkV1jlqzBB*; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d85ccee051f4b41b6b4d4d63f7f80366250f637b GIT binary patch literal 444 zcmV;t0YmOL8h$_n4^hJ~HZYcE-N`pntA*Q$c6*{_e8L%gPS=5Sdzq+b(&L%> zEWSjmCq{qp1NU$mmvIp{a4VgMuz*8&ntV9gZKEc(@B(ilyoM{O)$j~YlndX1cB3XX zmCIekrJUWwdIi7IsEKuaZOJRK>tgK{twu-CyAN$|G&_u@l^=6L`D$Gq47$RZh-MlN zr1%kc^Lzm(mCgQ+6XCp7ewmRN-N$(>Rdqay2g>d&E1SE7*UHX3MqkI5Zo?p+$FPxn mu!`L2E}HepVE8`44m0000 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8dd984202f2485598a727fabee82276d395c2794 GIT binary patch literal 961 zcmV;y13vtTP)TJ`+d0ytsyR*CQ zG`o4x9}XOz&;L8`d7tMw!!d?R2Vzvw8~e8~(t((5V<`qvk2M&wX~zO=%!riN#;}=k z%*M+&k|jTkwXqIo@*`n8dn$-@;{p7p`ImSP=Vf;1;Ej^apLh}{c7jL+w_;~0$=6sG zHdWjns~WN;7%2JtfR*TKm&{^(TnhSA1g!?y<=CF-w}-5fJ4^hjUM$m`3VRIoihwczw!kjF| zoRA#~o6X@PV)RAKB7QA$cDEw5y7UC!U{R*8;E&M#f`#Ke+`_P_hfiLB7e(wm*XmY5 z?kgD;q8 zv4%pnCv1vWaS1*s#r!MVub7C?t9V~?p)2an`C;FK$1t4fheOsAHs=~$mumP_I!~hr z9oLGPbSO9B6I?BofUejN;7#evS%X(_fB3D%uQ&tG9bZXpWf*6*N<0>!0{bHQ zN4QoxtX)!lxe2e~2|N|{CkM$@nk)0S*dV>iaWN600zYFN&c+RxC!LR#xJ9Z#@Hl2= zenZkPvg;`G95tZ=yYQmazm`dja~7U$@c{2&+a%*pnoxmlQkz&D#EL_FAc*u!m44D_ zyU|6$&#*p|tq)mSWd1Fo0wZJW+d?M@brk;-nh3`u{>nvno=PyiA&Nc5D4vpN0!!|7FA} z<9J+&JEXQ#klBEnv7<07pP}8j3ztfrz6iDik4SUPWX@ehdbo#iJx&Pu7eVkwe3v_n z6?_XW$88#oai%o=tun!I9+)Q$X?IFf0Q`u1aj`T@w}r7Ti97I|=0U3u&uG5ycl@$g z;1s-|`5Il*JR=X5^dr)h>hw+$DPw^&O^)?PL$NlQrav4CT!XDGh-?kre}XC=l3rac j_SJDcrU_j-)Q0{6A2%t0p#qa#00000NkvXXu0mjf0W-fr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..29c7f7fca7a3931ebee25a6d624583b273d97418 GIT binary patch literal 444 zcmV;t0Ym{ip^cYTgA>iM0d-VZo&YTVpvN) mP)2U`E$a2r= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..01541e30e1132e46a80a9032f40ead581f491ba4 GIT binary patch literal 965 zcmV;$13LVPP)<5Ypy2x&lA|fOrNQvqyj4o1(LLy6OEF{e=EYz_wjF_@gQ_nbE?DaAGd~@dS zbez2Cg$?_xz1F+lea`;Y+TYjkR^asTS%}Tq+A}c@hHbNz5z;Xp~)yO=eakalcDEejgJDx4cWmtjM!9l=ZJ z%s4v3b|ic@M2z5A6`TcsEpoOu612RugKsb|Td&~HSo;-oN7>xm@EMGlya3M&?mXA> zMkGs1PKBuM%xAHo1HNB+@}^|#Q^K}Ce0D`lf$j!k1|#<{^kfc)N&&r?AAapYurJp7 za8HcBuQc+Qac? zh?{P7k+2tQGTWN4H6`ZX3Mw!#!n`T8LQqTbzo4;jQVSZ)=GUvg=d>D~kz-iyybf)_ zVX5TL!b4I$`2c^kNXRs)1$7y^BOQ+17`l{`E>v&eEU9Nzq$YV4red$gkM-HOH0ECh zPZ_65_vjv}?G$3x;#TY|9Lr~DFYXQzn=-QbcobJOh#3tPX>*TAJ#cE+zreM)8Ql5^=?{kji==O)EM!}({U@m6 nVd|s3IOKv1JP4NU9_=hPxz^m{sUf?f2Ven#iDB&HJ@fDBp2MgE^ z-=?+?n^=mbRBO0}GG@_@VH{vI&Ds+m3wo>3KDc!?u?508?o-KkTfM!#_%&r!s2SWIre zp5R6Uqbh!4y(VsmmWy>+)EeDG;R@P9H0O+7qX#?AcBfhM(bV=tdk!7Wp1@}eCI2Rb z0en=s{3&!c8~F}qRJQiL%2Qax9R4T1-^}15`tc!#<;4AsirZS4wc%Of$xKuiCvc2c UwB$+nMF0Q*07*qoM6N<$g1n)@NB{r; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d558046d501247504caf2ca153449a480e6597eb GIT binary patch literal 794 zcmV+#1LgdQP)zJ4^q^^C|rrO2v==v z(ISGNR*|%*ZA3(oo0dhn2yG!QiV!ji6tbNpksze~Dvdf#i~HU@^UZbcc)k8Cdf|ib zp7Wk_-tW%+{sz_=5;5dR0<9Yg%K~?RYLlZ{wyZe) zia|NLl+({YubQbKL7mPBJ^+ozp8$T!_XVggF}Qkh@`|wwaHDeDTAe{j&O0PuRIA~% zvG9RU;LW^B_-G9flUMB`v>U=#C*U=3OuUp5jLSC$oK+i>ZCo7xr#J+> zaB}`I{q`_HC-FYw87D~}&>(&qxB^TW|CDTLacU@Dm$JY;pk*n7nq=FdV7oHsRAiSp z8Q`i~Mz-x`#5F|`|R=$j4%{WwF^= zwxFo{N;gmkoWpx7J|<U?SH7DaLLaB4=6l<1;K8TEg{@$<+O*i8PyI%Img#1f=V4@ z_k;`rfP<7i;Fl{Xwu8P+=3ar`;$3sX1RbV$BVP7Z2*NLkuAmjaEc)0C+@kmz{oFi1 z27a689Nss%HBKQeYJpw~-#}37WtIML@Nohd4iGXd-OPC Y0frA*pVe{s-v9sr07*qoM6N<$g7TGNo&W#< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6c2f588aacdb84b41a79181cf9bf45de661ff481 GIT binary patch literal 447 zcmV;w0YLtVP)B|3n%ML4iYMLKyU3!~LGTUcRUG!3T$X&-a~k?>(2j zaU}e29KtlV!s)E-8@;IEBQ`JNoURzg0)FB)PT^K~0$1?^Z*ZZ#I#lrlPqB`3 z_<~1x8_p%Sf@RD`Q*AX|K^+e;fJ@lKM2ht$-pt-Wa_iCVbhMiDkKjY9R#NRnSWTy= z)Af0Liq^pxHSi4&aR!$$i5s|?%ENeu!*~^rM7v|u#4o(gom6m5d9enLVi7N~-KP7? zsEKXNM>v+Xd&)fRicUsNY~gcD+!1>Y)_&1y)Q`S@(Dp_1htZ7kk7_uJo-PJG$*CzX zI0FZ=xrRG29>*ZQeU p>xqXtC3m}v=Kht$*VECi_zkC=ScJ%G4z2(I002ovPDHLkV1iiS%}f9Q literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d1eee378ea192bb0235e60408c5fa69b0bbcd3a6 GIT binary patch literal 947 zcmV;k15EshP)F)6f(%+n3|0_B zkp{VlidGf0$TkTrA|#@P5Vi;cZBe=zTvWEuN>a)c)0~N?tAad zow?naT=a(n?|shyJkS4}^S;k{&gCLQr2{^y=!@r%G0_2^ZDTDCq89C7*ybH8uqzW% zZo7gr=U9SwF_s0tfUVIkWcIP(E}07<-FN|is9%Wz^k%%}_^8DB6R%-mCxle+C{C1u ze2<5MQ^n)as$shy2TEgp!87P;SIq6$TXOnqIIV`*^*EgM9}Zh3o6O0QkFRjgY=qo^ z?f5%$at>Q?MR3+)f7bU#v~ywGZ}MyF!Am$(67~shoK8qLKF4@AXad`?EI7AePxOuA z9W2d!EDhUeaQ4I);bU9)Ec}m0%`C;$*l}dEym;tcvbk$ zV{LBcmm-aBzyPxD%h1e2!+%{jCJ;2oYl?Cj+=O_&sWh<$F+D18?zWS9Ftz+Gr?)VRDuee zl~&~&td=rymy}-~#`|~`uLu9C5c$0N%seP<+36PXS_&#KBIS+iaJTdccS}j~5h(}3 zD_E3`83qaS{R$nCPUjCoaRs;FY3aCVTGh=Emr6DI#_(B) z`BWEY(os5?e%*(o>D)luW>Kd;m4dj)l0oE96W}f!gje-xc5t2 z+6v30tI;}K8MYy8!W~i>(iWz=B%YMYkG#;@DHZZZOWclL77JXBx7AmX6VeJ6zmICX zwQR>VofJ~W3hY+D0?pcP^Yn*9fd{3Ny)5KV^!+EO;zenj#^bp { + 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 + + + + + -
+
+ +
+
+ +