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:
+37
-1
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user