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.
This commit is contained in:
Bohdan Triapitsyn
2026-06-10 00:20:23 +03:00
parent 161d11021c
commit 9cf79a8890
46 changed files with 1061 additions and 25 deletions
@@ -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;
};