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 + + + + + -
+
+ +
+
+ +