2026-06-10 13:50:14 +03:00
|
|
|
import { create } from 'zustand';
|
2026-07-21 20:52:20 +03:00
|
|
|
import type { Event, SessionStatus } from '@opencode-ai/sdk/v2/client';
|
2026-06-10 13:50:14 +03:00
|
|
|
import { normalizeProjectPath } from '@/lib/projectResolution';
|
2026-07-23 15:45:53 +03:00
|
|
|
import {
|
|
|
|
|
observeSessionActivityEvent,
|
|
|
|
|
reconcileSessionActivitySnapshot,
|
|
|
|
|
removeSessionOrdering,
|
|
|
|
|
} from './session-ordering';
|
2026-06-10 13:50:14 +03:00
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
// 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.
|
2026-06-10 13:50:14 +03:00
|
|
|
//
|
|
|
|
|
// 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';
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
type GlobalSessionStatusEntry = { status: SessionStatus; directory: string };
|
2026-06-10 13:50:14 +03:00
|
|
|
|
|
|
|
|
type GlobalSessionStatusState = {
|
|
|
|
|
statusById: Map<string, GlobalSessionStatusEntry>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => ({
|
|
|
|
|
statusById: new Map(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-07-23 15:45:53 +03:00
|
|
|
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)
|
|
|
|
|
);
|
2026-06-10 13:50:14 +03:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
const setStatus = (sessionId: string, directory: string, status: SessionStatus | { type: 'idle' }): void => {
|
2026-06-10 13:50:14 +03:00
|
|
|
useGlobalSessionStatusStore.setState((state) => {
|
|
|
|
|
const current = state.statusById.get(sessionId);
|
2026-07-21 20:52:20 +03:00
|
|
|
if (status.type === 'idle') {
|
2026-06-10 13:50:14 +03:00
|
|
|
if (!current) return state;
|
|
|
|
|
const next = new Map(state.statusById);
|
|
|
|
|
next.delete(sessionId);
|
|
|
|
|
return { statusById: next };
|
|
|
|
|
}
|
2026-07-23 15:45:53 +03:00
|
|
|
if (current && current.directory === directory && statusesEqual(current.status, status)) return state;
|
2026-06-10 13:50:14 +03:00
|
|
|
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;
|
2026-07-21 20:52:20 +03:00
|
|
|
const type = normalizeStatusType(props.status?.type);
|
|
|
|
|
setStatus(
|
|
|
|
|
props.sessionID,
|
|
|
|
|
normalizeDirectory(directory),
|
|
|
|
|
type === 'idle' ? { type: 'idle' } : { ...(props.status ?? {}), type } as SessionStatus,
|
|
|
|
|
);
|
2026-07-23 15:45:53 +03:00
|
|
|
observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active');
|
2026-06-10 13:50:14 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
case 'session.idle':
|
|
|
|
|
case 'session.error': {
|
|
|
|
|
const props = payload.properties as { sessionID?: string } | undefined;
|
|
|
|
|
if (typeof props?.sessionID === 'string' && props.sessionID) {
|
2026-07-21 20:52:20 +03:00
|
|
|
setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' });
|
2026-07-23 15:45:53 +03:00
|
|
|
observeSessionActivityEvent(props.sessionID, 'settled');
|
2026-06-10 13:50:14 +03:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-23 15:45:53 +03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-10 13:50:14 +03:00
|
|
|
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 ?? []);
|
2026-07-23 15:45:53 +03:00
|
|
|
const activeSessionIds = Object.entries(raw)
|
|
|
|
|
.filter(([, status]) => normalizeStatusType(status?.type) !== 'idle')
|
|
|
|
|
.map(([sessionId]) => sessionId);
|
|
|
|
|
reconcileSessionActivitySnapshot(activeSessionIds, known);
|
2026-06-10 13:50:14 +03:00
|
|
|
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') {
|
2026-07-21 20:52:20 +03:00
|
|
|
if (current && (current.directory === directory || known.has(sessionId))) {
|
2026-06-10 13:50:14 +03:00
|
|
|
next.delete(sessionId);
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-07-21 20:52:20 +03:00
|
|
|
const normalizedStatus = { ...status, type } as SessionStatus;
|
2026-07-23 15:45:53 +03:00
|
|
|
if (!current || current.directory !== directory || !statusesEqual(current.status, normalizedStatus)) {
|
2026-07-21 20:52:20 +03:00
|
|
|
next.set(sessionId, { status: normalizedStatus, directory });
|
2026-06-10 13:50:14 +03:00
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return changed ? { statusById: next } : state;
|
|
|
|
|
});
|
|
|
|
|
};
|