diff --git a/packages/ui/src/hooks/useTraySync.ts b/packages/ui/src/hooks/useTraySync.ts index 076fa1b4..3f860119 100644 --- a/packages/ui/src/hooks/useTraySync.ts +++ b/packages/ui/src/hooks/useTraySync.ts @@ -4,6 +4,8 @@ import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive } f 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 { useNotificationStore } from '@/sync/notification-store'; import { respondToPermission } from '@/sync/session-actions'; import { @@ -236,8 +238,18 @@ const collectLiveData = (): LiveData => { 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'); + } + + // 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 ?? {})) { @@ -259,6 +271,46 @@ const collectLiveData = (): LiveData => { 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((a, b) => updatedAt(b) - updatedAt(a)) + .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; @@ -294,8 +346,19 @@ const buildSnapshot = (instanceName: string): TraySnapshot => { 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 ?? fromStores ?? 'idle'; + }; + const rollupStatus = (family: string[]): TraySessionStatus => { - const statuses = family.map((id) => live.statusById.get(id) ?? 'idle'); + const statuses = family.map((id) => resolveStatus(id)); if (statuses.includes('busy')) return 'busy'; if (statuses.includes('retry')) return 'retry'; return 'idle'; @@ -339,7 +402,6 @@ export const useTraySync = (): void => { // 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); @@ -355,6 +417,21 @@ export const useTraySync = (): void => { 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 a single push, while staying near-instant for discrete events like // a new session appearing. @@ -414,6 +491,9 @@ export const useTraySync = (): void => { const unsubscribeProjects = useProjectsStore.subscribe(() => scheduleFlush()); const unsubscribeWorktrees = useSessionUIStore.subscribe(() => scheduleFlush()); const unsubscribeGit = useGitStore.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()); // Make the tray self-sufficient: load the full cross-project list now // (independent of the sidebar) and refresh it periodically so sessions from @@ -421,6 +501,11 @@ export const useTraySync = (): void => { 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()); @@ -449,12 +534,14 @@ export const useTraySync = (): void => { if (flushTimer !== null) window.clearTimeout(flushTimer); window.clearInterval(interval); window.clearInterval(refreshInterval); + window.clearInterval(globalStatusInterval); window.clearInterval(usageRefreshTick); unsubscribeNotif(); unsubscribeGlobal(); unsubscribeProjects(); unsubscribeWorktrees(); unsubscribeGit(); + unsubscribeGlobalStatus(); unsubscribeQuota(); unsubscribeRegistry?.(); for (const unsub of storeUnsubs.values()) unsub(); diff --git a/packages/ui/src/sync/global-session-status.ts b/packages/ui/src/sync/global-session-status.ts new file mode 100644 index 00000000..4c631837 --- /dev/null +++ b/packages/ui/src/sync/global-session-status.ts @@ -0,0 +1,118 @@ +import { create } from 'zustand'; +import type { Event } from '@opencode-ai/sdk/v2/client'; +import { normalizeProjectPath } from '@/lib/projectResolution'; + +// Live busy/retry status for sessions in directories WITHOUT a synced child +// store. The global event stream (`/api/global/event/ws`) carries status +// events for every directory, but the sync dispatcher can only apply them to +// an existing child store — events for unopened directories used to be +// dropped. They land here instead, so cross-project consumers (the macOS tray) +// see live status for all sessions, not just the synced ones. +// +// Only non-idle entries are kept; absence means idle. Entries carry their +// directory so a polled per-directory snapshot can authoritatively replace +// that directory's slice (the server omits idle sessions from snapshots). + +type ActiveStatusType = 'busy' | 'retry'; + +type GlobalSessionStatusEntry = { status: ActiveStatusType; directory: string }; + +type GlobalSessionStatusState = { + statusById: Map; +}; + +export const useGlobalSessionStatusStore = create(() => ({ + statusById: new Map(), +})); + +const normalizeStatusType = (type: unknown): ActiveStatusType | 'idle' => + type === 'busy' ? 'busy' : type === 'retry' ? 'retry' : 'idle'; + +// Both write paths normalize the directory key, so a polled snapshot can +// authoritatively replace entries written by events (and vice versa) even when +// the two sources format the same path differently (trailing slash, …). +const normalizeDirectory = (directory: string): string => + normalizeProjectPath(directory) ?? directory; + +const setStatus = (sessionId: string, directory: string, status: ActiveStatusType | 'idle'): void => { + useGlobalSessionStatusStore.setState((state) => { + const current = state.statusById.get(sessionId); + if (status === 'idle') { + if (!current) return state; + const next = new Map(state.statusById); + next.delete(sessionId); + return { statusById: next }; + } + if (current && current.status === status && current.directory === directory) return state; + const next = new Map(state.statusById); + next.set(sessionId, { status, directory }); + return { statusById: next }; + }); +}; + +// Event-driven path: called by the sync dispatcher for status-bearing events +// whose directory has no child store. Mirrors the child reducer's semantics +// (`session.idle` / `session.error` both resolve to idle). +export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => { + switch (payload.type) { + case 'session.status': { + const props = payload.properties as { sessionID?: string; status?: { type?: string } } | undefined; + if (typeof props?.sessionID !== 'string' || !props.sessionID) return; + setStatus(props.sessionID, normalizeDirectory(directory), normalizeStatusType(props.status?.type)); + return; + } + case 'session.idle': + case 'session.error': { + const props = payload.properties as { sessionID?: string } | undefined; + if (typeof props?.sessionID === 'string' && props.sessionID) { + setStatus(props.sessionID, normalizeDirectory(directory), 'idle'); + } + return; + } + default: + return; + } +}; + +// Polled path: an authoritative `/session/status?directory=X` snapshot. Entries +// missing from the snapshot are idle now — cleared both by directory key and by +// the caller's session-id list (the server may report a canonicalized directory +// that differs from the key an event wrote, e.g. via symlinks). Seeds the +// initial state (events only deliver changes) and reconciles missed events. +export const applyGlobalSessionStatusSnapshot = ( + rawDirectory: string, + raw: Record, + knownSessionIds?: Iterable, +): void => { + const directory = normalizeDirectory(rawDirectory); + const known = new Set(knownSessionIds ?? []); + useGlobalSessionStatusStore.setState((state) => { + let changed = false; + const next = new Map(state.statusById); + + for (const [sessionId, entry] of state.statusById) { + if ((entry.directory === directory || known.has(sessionId)) && !(sessionId in raw)) { + next.delete(sessionId); + changed = true; + } + } + + for (const [sessionId, status] of Object.entries(raw)) { + const type = normalizeStatusType(status?.type); + const current = next.get(sessionId); + if (type === 'idle') { + if (current && current.directory === directory) { + next.delete(sessionId); + changed = true; + } + continue; + } + if (!current || current.status !== type || current.directory !== directory) { + next.set(sessionId, { status: type, directory }); + changed = true; + } + } + + return changed ? { statusById: next } : state; + }); +}; diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 1aff7111..02a51c59 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -33,6 +33,7 @@ import { useConfigStore } from "@/stores/useConfigStore" import { useTodosPersistStore } from "@/stores/useTodosPersistStore" import { toast } from "@/components/ui" import { appendNotification } from "./notification-store" +import { applyGlobalSessionStatusEvent } from "./global-session-status" import type { State } from "./types" import type { SessionStatus } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@/types/permission" @@ -1274,6 +1275,11 @@ function handleEvent( } applySessionEventToGlobalSessions(payload) + // Keep the cross-project status map current for ALL directories (mirrors the + // global-session handling above). Child stores remain the primary source for + // synced directories; this map covers sessions a child store doesn't list + // (unopened directories, or list/status races for just-created sessions). + applyGlobalSessionStatusEvent(directory, payload) // Global events if (directory === "global" || !directory) {