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.
@@ -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());
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
{
|
||||
"from": "resources/icons/icon.ico",
|
||||
"to": "icons/icon.ico"
|
||||
},
|
||||
{
|
||||
"from": "resources/icons/tray",
|
||||
"to": "icons/tray"
|
||||
}
|
||||
],
|
||||
"afterPack": "scripts/after-pack.cjs",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"
|
||||
stroke="#000" stroke-linejoin="round" stroke-linecap="round">
|
||||
<g stroke-width="2.3">
|
||||
<path d="M16 2.5 L28.5 9.5 L28.5 23 L16 30 L3.5 23 L3.5 9.5 Z"/>
|
||||
<path d="M3.5 9.5 L16 16.25 L28.5 9.5"/>
|
||||
<path d="M16 16.25 L16 30"/>
|
||||
</g>
|
||||
<!-- thinner stroke for the diamond on the top face -->
|
||||
<path stroke-width="1.5" d="M16 6.5 L21.5 9.4 L16 12.3 L10.5 9.4 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 455 B |
|
After Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 861 B |
|
After Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 869 B |
|
After Width: | Height: | Size: 435 B |
|
After Width: | Height: | Size: 872 B |
|
After Width: | Height: | Size: 435 B |
|
After Width: | Height: | Size: 888 B |
|
After Width: | Height: | Size: 437 B |
|
After Width: | Height: | Size: 886 B |
|
After Width: | Height: | Size: 434 B |
|
After Width: | Height: | Size: 904 B |
|
After Width: | Height: | Size: 439 B |
|
After Width: | Height: | Size: 911 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 908 B |
|
After Width: | Height: | Size: 437 B |
|
After Width: | Height: | Size: 936 B |
|
After Width: | Height: | Size: 441 B |
|
After Width: | Height: | Size: 937 B |
|
After Width: | Height: | Size: 439 B |
|
After Width: | Height: | Size: 937 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 941 B |
|
After Width: | Height: | Size: 446 B |
|
After Width: | Height: | Size: 962 B |
|
After Width: | Height: | Size: 438 B |
|
After Width: | Height: | Size: 967 B |
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 961 B |
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 965 B |
|
After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 794 B |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 947 B |
@@ -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 };
|
||||
};
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string> => {
|
||||
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<string, TraySessionStatus>;
|
||||
branchByDirectory: Map<string, string>;
|
||||
approvals: TrayApproval[];
|
||||
titleById: Map<string, string>;
|
||||
};
|
||||
|
||||
const collectLiveData = (): LiveData => {
|
||||
const statusById = new Map<string, TraySessionStatus>();
|
||||
const branchByDirectory = new Map<string, string>();
|
||||
const approvals: TrayApproval[] = [];
|
||||
const titleById = new Map<string, string>();
|
||||
|
||||
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<string, string>(live.titleById);
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
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<string>();
|
||||
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<string, () => void>();
|
||||
|
||||
const rebindStores = () => {
|
||||
if (disposed) return;
|
||||
let stores;
|
||||
try {
|
||||
stores = getSyncChildStores();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const live = new Set<string>();
|
||||
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<void>) = 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
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -219,6 +219,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
defaultCombo: 'mod+alt+n',
|
||||
label: 'New Mini Chat window',
|
||||
description: 'Open a new Mini Chat draft window',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'submit_message',
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -4,9 +4,95 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>OpenChamber Mini Chat</title>
|
||||
|
||||
<script>
|
||||
// Apply theme + splash colors before first paint so the window opens on a
|
||||
// branded splash instead of flashing white while React/CSS load.
|
||||
(function() {
|
||||
try {
|
||||
var themeMode = localStorage.getItem('themeMode');
|
||||
var variant = localStorage.getItem('selectedThemeVariant');
|
||||
var useSystem = localStorage.getItem('useSystemTheme');
|
||||
var isDark;
|
||||
|
||||
if (themeMode === 'dark') {
|
||||
isDark = true;
|
||||
} else if (themeMode === 'light') {
|
||||
isDark = false;
|
||||
} else if (themeMode === 'system' || useSystem === null || useSystem === 'true') {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
} else if (variant === 'light' || variant === 'dark') {
|
||||
isDark = variant === 'dark';
|
||||
} else {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
document.documentElement.classList.add(isDark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-splash-variant', isDark ? 'dark' : 'light');
|
||||
document.documentElement.style.setProperty('color-scheme', isDark ? 'dark' : 'light');
|
||||
|
||||
var splashBgLight = localStorage.getItem('splashBgLight');
|
||||
var splashFgLight = localStorage.getItem('splashFgLight');
|
||||
var splashBgDark = localStorage.getItem('splashBgDark');
|
||||
var splashFgDark = localStorage.getItem('splashFgDark');
|
||||
|
||||
if (splashBgLight) document.documentElement.style.setProperty('--splash-background-light', splashBgLight);
|
||||
if (splashFgLight) document.documentElement.style.setProperty('--splash-stroke-light', splashFgLight);
|
||||
if (splashBgDark) document.documentElement.style.setProperty('--splash-background-dark', splashBgDark);
|
||||
if (splashFgDark) document.documentElement.style.setProperty('--splash-stroke-dark', splashFgDark);
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply theme:', error);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* A plain themed backdrop — no logo. The mini-chat content renders its
|
||||
own (single) OpenChamber cube in its empty state, so a cube here would
|
||||
briefly show a second, differently-sized one before handing off. The
|
||||
backdrop matches bg-background, so fading it out is seamless. */
|
||||
:root {
|
||||
--splash-background-dark: #151313;
|
||||
--splash-background-light: #FFFCF0;
|
||||
--splash-background: var(--splash-background-dark);
|
||||
}
|
||||
html[data-splash-variant='light'] { --splash-background: var(--splash-background-light); }
|
||||
html[data-splash-variant='dark'] { --splash-background: var(--splash-background-dark); }
|
||||
|
||||
#initial-loading {
|
||||
background-color: var(--splash-background);
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
#initial-loading.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module" src="/src/mini-chat-main.tsx"></script>
|
||||
</head>
|
||||
<body class="h-full bg-background text-foreground">
|
||||
<div id="root" class="h-full"></div>
|
||||
<div id="root" class="h-full">
|
||||
<!-- Themed backdrop until the mini-chat React app is ready (dismissed in
|
||||
ElectronMiniChatApp). No logo here on purpose — the content renders
|
||||
the single OpenChamber cube itself. -->
|
||||
<div id="initial-loading"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Safety net: never let the splash hang if the app fails to dismiss it.
|
||||
setTimeout(function() {
|
||||
var loading = document.getElementById('initial-loading');
|
||||
if (loading) {
|
||||
loading.classList.add('fade-out');
|
||||
setTimeout(function() { loading.remove(); }, 300);
|
||||
}
|
||||
}, 8000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||