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 { opencodeClient } from '@/lib/opencode/client'; import { useGlobalSessionStatusStore, applyGlobalSessionStatusSnapshot } from '@/sync/global-session-status'; import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering'; import { useNotificationStore } from '@/sync/notification-store'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { respondToPermission } from '@/sync/session-actions'; import { useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions, resolveGlobalSessionDirectory, } from '@/stores/useGlobalSessionsStore'; import { useQuotaStore } from '@/stores/useQuotaStore'; import { QUOTA_PROVIDERS, formatWindowLabel, formatQuotaValueLabel } from '@/lib/quota'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGitStore } from '@/stores/useGitStore'; import { useUIStore } from '@/stores/useUIStore'; import { resolveProjectForSessionDirectory, normalizeProjectPath } from '@/lib/projectResolution'; import type { ProjectEntry } from '@/lib/api/types'; import type { WorktreeMetadata } from '@/types/worktree'; import { toast } from '@/components/ui'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; // Native tray/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 desktop platforms with a native tray/menu bar — main.mjs // no-ops the command elsewhere, 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 = 500; // 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; // Secondary line for the menu row: "project · branch" (rendered as sublabel). subtitle: string; }; type TrayApproval = { kind: 'permission' | 'question'; id: string; sessionId: string; sessionTitle: string; label: string; directory: string; }; type TrayUsageRow = { label: string; value: string }; type TrayUsageGroup = { provider: string; rows: TrayUsageRow[]; status: string | null }; type TrayUsage = { mode: 'usage' | 'remaining'; groups: TrayUsageGroup[] }; 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; // Provider rate-limit usage, only for providers the user enabled for the // dropdown (same "configured to show" rule as the header/mobile). Empty // groups → the tray omits the Usage submenu entirely. usage: TrayUsage; // Number of chats (root sessions) with unseen activity, for the macOS dock // badge. 0 when the user disabled the badge — the main process clears it. dockBadgeCount: number; }; // 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 isTrayPlatform = (): boolean => { if (typeof window === 'undefined') return false; const platform = (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__; return platform === 'darwin' || platform === 'win32' || platform === 'linux'; }; const isTrayEnabled = (): boolean => typeof window !== 'undefined' && window.__OPENCHAMBER_ELECTRON__?.trayEnabled !== false; 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 compareSessionOrder = (left: Session, right: Session): number => ( compareSessionsByLifecycleOrder( left, right, useSessionPinnedStore.getState().ids, useSessionOrderingStore.getState().rankById, ) ); const basenameOf = (p: string): string => { const norm = p.replace(/\\/g, '/').replace(/\/+$/, ''); const idx = norm.lastIndexOf('/'); return idx >= 0 ? norm.slice(idx + 1) : norm; }; // Resolve the "project · branch" metadata line for a session from its directory, // covering both project-root sessions and worktree sessions (which map back to // their parent project). Branch prefers the live VCS value; falls back to the // worktree's recorded branch when the directory isn't currently synced. const resolveSessionSubtitle = ( directory: string, session: Session, projects: ProjectEntry[], worktreesByProject: Map, branchByDirectory: Map, ): string => { if (!directory) return ''; const project = resolveProjectForSessionDirectory(projects, worktreesByProject, directory); const globalProjectName = (session as { project?: { name?: string } | null }).project?.name; const projectName = project?.label?.trim() || globalProjectName?.trim() || basenameOf(directory); const normDir = normalizeProjectPath(directory); // Branch resolution, most-authoritative first: live VCS from the sync store, // then the git store's cached status (covers project-root sessions whose // directory isn't actively synced), then recorded worktree metadata. let branch = (normDir && branchByDirectory.get(normDir)) || ''; if (!branch) { for (const [dir, gitState] of useGitStore.getState().directories) { if (normalizeProjectPath(dir) === normDir) { branch = gitState.status?.current ?? ''; break; } } } if (!branch) { for (const worktrees of worktreesByProject.values()) { const match = worktrees.find((wt) => normalizeProjectPath(wt.path) === normDir); if (match?.branch) { branch = match.branch; break; } } } return branch ? `${projectName} · ${branch}` : projectName; }; // Build the usage groups exactly like the header/mobile: only providers the // user enabled for the dropdown AND that report as configured. Window rows only // (the headline limits) — model breakdowns stay in the full UI. const buildUsage = (): TrayUsage => { const { results, dropdownProviderIds, displayMode } = useQuotaStore.getState(); const mode: TrayUsage['mode'] = displayMode === 'remaining' ? 'remaining' : 'usage'; if (!dropdownProviderIds.length) return { mode, groups: [] }; const byProvider = new Map(results.map((result) => [result.providerId, result])); const groups: TrayUsageGroup[] = []; for (const meta of QUOTA_PROVIDERS) { if (!dropdownProviderIds.includes(meta.id)) continue; const result = byProvider.get(meta.id); if (!result || result.configured !== true) continue; const rows: TrayUsageRow[] = []; for (const [label, window] of Object.entries(result.usage?.windows ?? {})) { const percent = mode === 'remaining' ? window.remainingPercent : window.usedPercent; rows.push({ label: formatWindowLabel(label), value: formatQuotaValueLabel(window.valueLabel, percent) }); } const status = !result.ok && result.error ? result.error : rows.length === 0 ? 'No rate limits reported' : null; groups.push({ provider: meta.name, rows, status }); } return { mode, groups }; }; // Mirrors the header's instance resolution (Header.refreshCurrentInstanceLabel): // the local origin shows as "Local OpenChamber"; a remote host shows its // configured name. Async because the host config is read over IPC. const resolveInstanceName = async (): Promise => { try { if (isDesktopLocalOriginActive()) return 'Local OpenChamber'; const localOrigin = (window as unknown as { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin; const runtimeApiBaseUrl = getRuntimeApiBaseUrl(); if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) return 'Local OpenChamber'; const cfg = await desktopHostsGet(); const match = cfg.hosts.find((host) => runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false); if (match?.label?.trim()) return redactSensitiveUrl(match.label.trim()); return 'Instance'; } catch { return ''; } }; // Live data lives in the directory-scoped sync child stores. Aggregate it once // into flat lookups so we can attach it to the global session list by id. type LiveData = { statusById: Map; branchByDirectory: Map; approvals: TrayApproval[]; titleById: Map; }; const collectLiveData = (): LiveData => { const statusById = new Map(); const branchByDirectory = new Map(); const approvals: TrayApproval[] = []; const titleById = new Map(); let stores; try { stores = getSyncChildStores(); } catch { return { statusById, branchByDirectory, approvals, titleById }; } for (const [directory, store] of stores.children.entries()) { const state = store.getState(); // Normalize the key so it matches the session directory regardless of // trailing slashes / separators. if (state.vcs?.branch) branchByDirectory.set(normalizeProjectPath(directory) ?? directory, state.vcs.branch); for (const session of state.session) { if (!session?.id) continue; titleById.set(session.id, session.title); } // Status comes from the status map itself, NOT from the session list — a // just-created session can have a live status entry before (or without) // appearing in this store's list, and the same session can be listed by // several stores. Never let one store's missing/idle entry clobber another // store's busy/retry. for (const [sessionId, status] of Object.entries(state.session_status ?? {})) { const type = status?.type; const mapped: TraySessionStatus = type === 'busy' ? 'busy' : type === 'retry' ? 'retry' : 'idle'; const existing = statusById.get(sessionId); if (!existing || existing === 'idle') statusById.set(sessionId, mapped); } 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 }; }; // Status for sessions outside the synced child stores arrives two ways, both // landing in useGlobalSessionStatusStore (the fallback in the rollup below): // - live: the global event stream carries status events for every directory; // the sync dispatcher routes the ones without a child store into the store; // - polled: events only deliver changes, so an initial per-directory snapshot // seeds the state and a slow poll reconciles anything missed. Per directory // because the upstream `/session/status` endpoint is directory-scoped // (querying it without a directory covers only the server's own cwd, NOT // all projects). // Directories worth polling: everywhere the tray's visible sessions live — // including synced ones, so the poll reconciles any status event a child // store missed (e.g. a session created from another window mid-race). Returns // each directory with the session ids the global list places there, so the // snapshot can authoritatively clear stale entries by session id. const collectStatusPollDirectories = (): Map => { const allSessions = useGlobalSessionsStore.getState().activeSessions; const rootDirs = new Set(); allSessions .filter((s) => s?.id && !s.parentID) .slice() .sort(compareSessionOrder) .slice(0, MAX_SESSIONS) .forEach((session) => { const directory = resolveGlobalSessionDirectory(session); if (directory) rootDirs.add(directory); }); const targets = new Map(); for (const session of allSessions) { if (!session?.id) continue; const directory = resolveGlobalSessionDirectory(session); if (!directory || !rootDirs.has(directory)) continue; const ids = targets.get(directory) ?? []; ids.push(session.id); targets.set(directory, ids); } return targets; }; const buildSnapshot = (instanceName: string): TraySnapshot => { const live = collectLiveData(); const notif = useNotificationStore.getState().index.session; // The list source is the GLOBAL store — every project/worktree the backend // knows about, independent of which directories this client has opened. Live // status/unread/branch are merged in by id where we have them (the session's // directory is synced); otherwise the row is shown as idle. const allSessions = useGlobalSessionsStore.getState().activeSessions; const titleById = new Map(live.titleById); const childrenByParent = new Map(); for (const session of allSessions) { if (!session?.id) continue; if (session.title) titleById.set(session.id, session.title); if (session.parentID) { const siblings = childrenByParent.get(session.parentID) ?? []; siblings.push(session.id); childrenByParent.set(session.parentID, siblings); } } const collectDescendants = (rootId: string): string[] => { const out: string[] = []; const stack = [...(childrenByParent.get(rootId) ?? [])]; const seen = new Set(); while (stack.length) { const id = stack.pop() as string; if (seen.has(id)) continue; seen.add(id); out.push(id); stack.push(...(childrenByParent.get(id) ?? [])); } return out; }; // A session is active if EITHER source says so: the synced child stores // (instant, but can miss sessions created outside this window) or the // cross-project status map (event-driven for every directory + polled // reconciliation). Requiring agreement would re-introduce the gaps. const globalStatusById = useGlobalSessionStatusStore.getState().statusById; const resolveStatus = (id: string): TraySessionStatus => { const fromStores = live.statusById.get(id); if (fromStores && fromStores !== 'idle') return fromStores; return globalStatusById.get(id)?.status.type ?? fromStores ?? 'idle'; }; const rollupStatus = (family: string[]): TraySessionStatus => { const statuses = family.map((id) => resolveStatus(id)); if (statuses.includes('busy')) return 'busy'; if (statuses.includes('retry')) return 'retry'; return 'idle'; }; const projects = useProjectsStore.getState().projects; const worktreesByProject = useSessionUIStore.getState().availableWorktreesByProject; const sessions: TraySession[] = allSessions .filter((s) => s?.id && !s.parentID) // root rows; sub-session work rolls up .slice() .sort(compareSessionOrder) .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, subtitle: resolveSessionSubtitle(directory, session, projects, worktreesByProject, live.branchByDirectory), }; }); const approvals = live.approvals.map((a) => ({ ...a, sessionTitle: titleById.get(a.sessionId) || '' })); // Dock badge: count chats (root sessions) with unseen activity over the FULL // cross-project list — not the MAX_SESSIONS-capped `sessions` above — so the // number is accurate even with many projects. A subtask's unseen rolls up to // its root only when the user opted into subtask notifications, matching the // sidebar's needs-attention rule. const ui = useUIStore.getState(); let dockBadgeCount = 0; if (ui.dockBadgeEnabled) { for (const session of allSessions) { if (!session?.id || session.parentID) continue; // roots only let familyUnseen = notif.unseenCount[session.id] ?? 0; if (familyUnseen === 0 && ui.notifyOnSubtasks) { familyUnseen = collectDescendants(session.id) .reduce((sum, id) => sum + (notif.unseenCount[id] ?? 0), 0); } if (familyUnseen > 0) dockBadgeCount += 1; } } return { sessions, approvals, instanceName, usage: buildUsage(), dockBadgeCount }; }; export const useTraySync = (): void => { React.useEffect(() => { if (!isTrayPlatform() || !isTrayEnabled() || !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(); }); // Seed + reconcile the cross-project status map. The live path is the // global event stream (captured by the sync dispatcher); this poll covers // sessions already busy before this window opened and any missed events. // Cheap: ~ms per directory, bounded by the tray's visible session count. const refreshGlobalStatus = async () => { const targets = collectStatusPollDirectories(); await Promise.all([...targets.entries()].map(async ([directory, sessionIds]) => { // null = fetch failed → keep that directory's current entries; // {} = authoritative "everything here is idle". const raw = await opencodeClient.getSessionStatusForDirectory(directory).catch(() => null); if (disposed || raw === null) return; applyGlobalSessionStatusSnapshot(directory, raw, sessionIds); })); }; // Coalesce bursts (e.g. token-by-token streaming updates a store rapidly) // into at most one push per FLUSH_DEBOUNCE_MS; discrete events surface within // that window. The main app UI stays instant via SSE/stores. const scheduleFlush = () => { if (disposed || flushTimer !== null) return; flushTimer = window.setTimeout(() => { flushTimer = null; flushNow(); }, FLUSH_DEBOUNCE_MS); }; // Event-driven: subscribe to each directory store so session create/update/ // status changes propagate immediately, and to the registry so stores for // newly-opened directories get wired up as they appear. const storeUnsubs = new Map void>(); const rebindStores = () => { if (disposed) return; let stores; try { stores = getSyncChildStores(); } catch { return; } const live = new Set(); for (const [directory, store] of stores.children.entries()) { live.add(directory); if (!storeUnsubs.has(directory)) { storeUnsubs.set(directory, store.subscribe(() => scheduleFlush())); } } for (const [directory, unsub] of storeUnsubs) { if (!live.has(directory)) { unsub(); storeUnsubs.delete(directory); } } }; let unsubscribeRegistry: (() => void) | null = null; try { unsubscribeRegistry = getSyncChildStores().subscribeRegistry(() => { rebindStores(); scheduleFlush(); }); } catch { // Sync provider not mounted yet — the fallback poll below recovers. } rebindStores(); const unsubscribeNotif = useNotificationStore.subscribe(() => scheduleFlush()); // The global store drives the session list. It updates instantly via SSE // for the active directory; subscribe so those land in the tray at once. const unsubscribeGlobal = useGlobalSessionsStore.subscribe(() => scheduleFlush()); // Project labels and discovered worktrees feed the "project · branch" // subtitle; refresh the tray when they change (deduped, so cheap). const unsubscribeProjects = useProjectsStore.subscribe(() => scheduleFlush()); const unsubscribeWorktrees = useSessionUIStore.subscribe(() => scheduleFlush()); const unsubscribeGit = useGitStore.subscribe(() => scheduleFlush()); // The dock-badge toggle and subtask-notification preference live here; a // change must re-push the snapshot so the badge appears/clears immediately. const unsubscribeUI = useUIStore.subscribe(() => scheduleFlush()); // Cross-project status map: fed live by the sync dispatcher from the global // event stream, and seeded/reconciled by the poll below. const unsubscribeGlobalStatus = useGlobalSessionStatusStore.subscribe(() => scheduleFlush()); const unsubscribeSessionOrder = useSessionOrderingStore.subscribe(() => scheduleFlush()); const unsubscribePinnedSessions = useSessionPinnedStore.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); // Global busy/retry status: fetch now and poll, so unsynced sessions don't // sit looking idle. Synced directories stay instant via their SSE stores. void refreshGlobalStatus(); const globalStatusInterval = window.setInterval(() => { void refreshGlobalStatus(); }, POLL_INTERVAL_MS); // Usage: push to the tray whenever the quota store changes, and do one // initial fetch for enabled providers so the submenu isn't empty on launch. const unsubscribeQuota = useQuotaStore.subscribe(() => scheduleFlush()); void useQuotaStore.getState().loadSettings().then(() => { if (disposed) return; const { dropdownProviderIds, results } = useQuotaStore.getState(); const needsFetch = dropdownProviderIds.length > 0 && dropdownProviderIds.some((id) => !results.some((r) => r.providerId === id)); if (needsFetch) void useQuotaStore.getState().fetchAllQuotas(); }); // Keep the Usage submenu current per the user's auto-refresh setting // (desktop-only; checked each tick so toggling it mid-session applies). const usageRefreshTick = window.setInterval(() => { const quota = useQuotaStore.getState(); if (quota.autoRefresh && quota.dropdownProviderIds.length > 0) void quota.fetchAllQuotas(); }, Math.max(30000, useQuotaStore.getState().refreshIntervalMs || 60000)); // 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); window.clearInterval(globalStatusInterval); window.clearInterval(usageRefreshTick); unsubscribeNotif(); unsubscribeGlobal(); unsubscribeProjects(); unsubscribeWorktrees(); unsubscribeGit(); unsubscribeUI(); unsubscribeGlobalStatus(); unsubscribeSessionOrder(); unsubscribePinnedSessions(); unsubscribeQuota(); unsubscribeRegistry?.(); for (const unsub of storeUnsubs.values()) unsub(); storeUnsubs.clear(); }; }, []); React.useEffect(() => { if (!isTrayPlatform() || !isTrayEnabled() || typeof window === 'undefined') return; const bridge = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__; const listen = bridge?.listen; if (typeof listen !== 'function') return; const handle = (action: TrayAction) => { switch (action.type) { case 'respond-permission': void respondToPermission(action.sessionId, action.id, action.response).catch(() => { toast.error('Failed to respond to permission request'); }); break; } }; let unlisten: null | (() => void | Promise) = null; listen(TRAY_ACTION_EVENT, (evt) => { const action = evt?.payload as TrayAction | undefined; if (!action || typeof action !== 'object' || typeof action.type !== 'string') return; handle(action); }) .then((fn) => { unlisten = fn; }) .catch(() => { /* ignore */ }); return () => { try { const result = unlisten?.(); if (result instanceof Promise) void result.catch(() => {}); } catch { // ignore } }; }, []); };