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

146 lines
5.8 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';
import {
observeSessionActivityEvent,
reconcileSessionActivitySnapshot,
removeSessionOrdering,
} from './session-ordering';
// 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' => {
if (type === 'busy') return 'busy';
if (type === 'retry') return 'retry';
return 'idle';
};
const statusesEqual = (left: SessionStatus, right: SessionStatus): boolean => (
left.type === right.type && JSON.stringify(left) === JSON.stringify(right)
);
// 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.directory === directory && statusesEqual(current.status, 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,
);
observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active');
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' });
observeSessionActivityEvent(props.sessionID, 'settled');
}
return;
}
case 'session.deleted': {
const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined;
const sessionId = props?.sessionID ?? props?.info?.id;
if (sessionId) removeSessionOrdering(sessionId);
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 ?? []);
const activeSessionIds = Object.entries(raw)
.filter(([, status]) => normalizeStatusType(status?.type) !== 'idle')
.map(([sessionId]) => sessionId);
reconcileSessionActivitySnapshot(activeSessionIds, known);
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.directory !== directory || !statusesEqual(current.status, normalizedStatus)) {
next.set(sessionId, { status: normalizedStatus, directory });
changed = true;
}
}
return changed ? { statusById: next } : state;
});
};