Files
openchamber/packages/ui/src/sync/global-session-status.ts
T

124 lines
5.0 KiB
TypeScript
Raw Normal View History

import { create } from 'zustand';
import type { Event, SessionStatus } from '@opencode-ai/sdk/v2/client';
import { normalizeProjectPath } from '@/lib/projectResolution';
// Shared live busy/retry index for every directory. Global events update it
// incrementally and authoritative directory snapshots reconcile it, so each
// sidebar row can subscribe to one leaf instead of every child store.
//
// 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: SessionStatus; 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: SessionStatus | { type: 'idle' }): void => {
useGlobalSessionStatusStore.setState((state) => {
const current = state.statusById.get(sessionId);
if (status.type === 'idle') {
if (!current) return state;
const next = new Map(state.statusById);
next.delete(sessionId);
return { statusById: next };
}
if (current && current.status.type === status.type && current.directory === directory
&& JSON.stringify(current.status) === JSON.stringify(status)) 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;
const type = normalizeStatusType(props.status?.type);
setStatus(
props.sessionID,
normalizeDirectory(directory),
type === 'idle' ? { type: 'idle' } : { ...(props.status ?? {}), type } as SessionStatus,
);
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), { type: '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 || known.has(sessionId))) {
next.delete(sessionId);
changed = true;
}
continue;
}
const normalizedStatus = { ...status, type } as SessionStatus;
if (!current || current.status.type !== type || current.directory !== directory
|| JSON.stringify(current.status) !== JSON.stringify(normalizedStatus)) {
next.set(sessionId, { status: normalizedStatus, directory });
changed = true;
}
}
return changed ? { statusById: next } : state;
});
};