fix(tray): show live activity for every session, not just one
The tray showed the busy indicator for at most one session at a time. Several gaps in how per-session status was sourced stacked up to that: - Status was derived by iterating each sync child store's session list, so a busy session missing from the list (created moments earlier from another window, the tray, or the API while session.created raced or the list got trimmed) was invisible even though the store's session_status map already held its busy entry. - The upstream /session/status endpoint is directory-scoped — querying it without a directory only covers the server's own cwd, so there was no authoritative cross-project snapshot to fall back on. - Status events for directories without a child store were dropped by the sync dispatcher, so sessions in unopened projects always rendered idle. Fix, layer by layer: - Add a cross-project session-status store (sync/global-session-status) fed two ways: the sync dispatcher now records status-bearing events (session.status / session.idle / session.error) for ALL directories, and the tray polls /session/status per visible-session directory to seed initial state and reconcile missed events. Snapshots clear stale entries both by directory key and by session id, so canonicalized (realpath) directory mismatches can't strand a busy entry. - Read live status straight from each child store's session_status map instead of via its session list, and never let one store's idle entry clobber another store's busy/retry for the same session. - Resolve a session as active when either source (child stores or the cross-project map) reports busy/retry, instead of letting the synced store's idle shadow the fallback. Verified end-to-end in the dev shell: two sessions running concurrently in different projects — including a brand-new session in the open project root, the exact case that failed — now both show busy in the tray, and both return to idle when they finish.
This commit is contained in:
@@ -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<string, string[]> => {
|
||||
const allSessions = useGlobalSessionsStore.getState().activeSessions;
|
||||
const rootDirs = new Set<string>();
|
||||
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<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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, GlobalSessionStatusEntry>;
|
||||
};
|
||||
|
||||
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => ({
|
||||
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<string, { type?: string }>,
|
||||
knownSessionIds?: Iterable<string>,
|
||||
): 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;
|
||||
});
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user