The tray controller called setToolTip and setContextMenu unconditionally on every snapshot push. On Linux both are synchronous D-Bus calls into plasmashell's StatusNotifierItem host. useTraySync debounced pushes at 120ms (up to ~8/sec during token streaming), so each push made 2 blocking D-Bus round-trips even when only dockBadgeCount changed or a token streamed into an already-listed session — i.e., tooltip and menu content didn't change. The native block has no JS-level log, is intermittent (depends on plasmashell load), and eventually freezes the Electron main thread until crash. setTitle and setImage were already deduped; setToolTip and setContextMenu were the gap. Mirror the existing lastX pattern: - tray.mjs: dedupe setToolTip (exact string compare via lastTooltip) and setContextMenu (via lastMenuKey, a lightweight signature of menu-affecting fields: sessions/approvals/usage). menuKey is a string concat, cheaper than buildMenu itself, so skipping buildMenu when the key matches also saves work. Cache-after-call ordering matches lastTitle/lastTooltip so a throw forces a retry rather than skipping one. destroy() resets both new vars. - useTraySync.ts: FLUSH_DEBOUNCE_MS 120 -> 500. Tray doesn't need sub-second updates; approvals are rare discrete events that flush through the debounce and the main app UI stays instant via SSE/stores. The first change is the real fix; the second is a complement. Either alone helps; together they eliminate the freeze under streaming.
630 lines
27 KiB
TypeScript
630 lines
27 KiB
TypeScript
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<string, WorktreeMetadata[]>,
|
|
branchByDirectory: Map<string, string>,
|
|
): 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<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();
|
|
// 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<string, string[]> => {
|
|
const allSessions = useGlobalSessionsStore.getState().activeSessions;
|
|
const rootDirs = new Set<string>();
|
|
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<string, string[]>();
|
|
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<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;
|
|
};
|
|
|
|
// 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<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());
|
|
// 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<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
|
|
}
|
|
};
|
|
}, []);
|
|
};
|