Session lists now promote a conversation when it starts working and again when it settles, instead of reacting to every streaming timestamp update. This keeps ordering responsive without bringing back the sidebar churn removed by the recent performance work. Apply the same user-visible order across Recent, project and worktree groups, session switchers, mobile navigation, widgets, the command palette, and the desktop tray. Preserve pinned priority, freeze timestamp fallback ordering, and keep child-session activity scoped to siblings under the same parent so it never moves the root conversation. Seed reconnect snapshots without synthetic jumps, clear ephemeral ranks on deletion and runtime changes, and cover lifecycle transitions, mixed root/child trees, metadata-only updates, and project-group ordering with regression tests.
146 lines
5.8 KiB
TypeScript
146 lines
5.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';
|
|
|
|
// 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;
|
|
});
|
|
};
|