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
+37 -1
View File
@@ -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;
};
+375
View File
@@ -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
}
};
}, []);
};
+1
View File
@@ -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',
+13 -1
View File
@@ -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))
}