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:
@@ -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