The spinner ran a CSS animation on every active row for the whole turn, repainting a composited layer at frame rate. Rows now carry a static dot — primary while running, info while unread — and the metadata slot on the right shows how long the turn has been going, updating once per second in the dot's colour. The counter is the motion the spinner used to provide, at 1 fps. Collapsed groups, folders and projects take the dot only, since one counter cannot speak for several running turns. Elapsed time is measured client-side because SessionStatus carries no timestamps, and starts are persisted so a reload resumes the same count. Two rules keep that honest. Only a liveness stamp — refreshed while a session is observed active, stamped as the page hides, and compared against the page's navigation start so a slow bootstrap is not charged to the absence — and a 90s adoption window may expire a record; a snapshot that cannot yet see a session is not evidence its turn ended. And a busy event is never read as a turn boundary, because OpenCode republishes busy at every step of the agent loop, so after a reload one of those repeats normally beats the first status snapshot. Idle and error events do end a turn, and retire the record with it. Snapshot reconciliation walks the running turns and asks whether the snapshot covers each one, rather than being handed everything it covers: only a live start can settle, so the pass scales with timing work instead of with the directory's session list, and allocates nothing per poll. Also applied to the mobile sessions sheet and session switcher. The shared duration ticker moves to hooks/ now that it has a second consumer.
168 lines
6.8 KiB
TypeScript
168 lines
6.8 KiB
TypeScript
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';
|
|
import {
|
|
observeSessionActivityTiming,
|
|
reconcileSessionActivityTiming,
|
|
removeSessionActivityTiming,
|
|
} from './session-activity-timing';
|
|
|
|
// 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');
|
|
// `retry` is still a running turn, so the elapsed counter keeps going.
|
|
observeSessionActivityTiming(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');
|
|
observeSessionActivityTiming(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);
|
|
removeSessionActivityTiming(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 ?? []);
|
|
// Built once as a set and shared by both consumers below; only non-idle
|
|
// sessions land here, so it stays small however long the directory's list is.
|
|
const activeSessionIds = new Set<string>();
|
|
for (const [sessionId, status] of Object.entries(raw)) {
|
|
if (normalizeStatusType(status?.type) !== 'idle') activeSessionIds.add(sessionId);
|
|
}
|
|
reconcileSessionActivitySnapshot(activeSessionIds, known);
|
|
// Timing asks the coverage question instead of being handed a list: a snapshot
|
|
// authoritatively covers the caller's session list plus every id it reports
|
|
// itself, and only the handful of sessions actually being timed need an
|
|
// answer. Reuses the sets already built above, so this allocates nothing.
|
|
reconcileSessionActivityTiming(
|
|
activeSessionIds,
|
|
(sessionId) => known.has(sessionId) || sessionId in raw,
|
|
);
|
|
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;
|
|
});
|
|
};
|