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:
Bohdan Triapitsyn
2026-06-10 14:01:10 +03:00
parent 079e3a9bd6
commit 89aa389e6c
3 changed files with 215 additions and 4 deletions
@@ -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;
});
};
+6
View File
@@ -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) {