perf(ui): streamline session sidebar state
This commit is contained in:
@@ -26,13 +26,75 @@ type GlobalSessionStatusEntry = { status: SessionStatus; directory: string };
|
||||
|
||||
type GlobalSessionStatusState = {
|
||||
statusById: Map<string, GlobalSessionStatusEntry>;
|
||||
activeSessionIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => ({
|
||||
statusById: new Map(),
|
||||
}));
|
||||
const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet<string> = new Set();
|
||||
|
||||
const normalizeStatusType = (type: unknown): ActiveStatusType | 'idle' => {
|
||||
const initialState: GlobalSessionStatusState = {
|
||||
statusById: new Map(),
|
||||
activeSessionIds: EMPTY_ACTIVE_SESSION_IDS,
|
||||
};
|
||||
|
||||
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => initialState);
|
||||
|
||||
// Runtime switching currently replaces statusById directly. Keep that boundary
|
||||
// synchronized without making normal status mutations derive membership again.
|
||||
const storeSetState = useGlobalSessionStatusStore.setState;
|
||||
type GlobalSessionStatusStateUpdate = GlobalSessionStatusState
|
||||
| Partial<GlobalSessionStatusState>
|
||||
| ((state: GlobalSessionStatusState) => GlobalSessionStatusState | Partial<GlobalSessionStatusState>);
|
||||
|
||||
function setSynchronizedState(
|
||||
partial: GlobalSessionStatusStateUpdate,
|
||||
replace?: false,
|
||||
): void;
|
||||
function setSynchronizedState(
|
||||
partial: GlobalSessionStatusState | ((state: GlobalSessionStatusState) => GlobalSessionStatusState),
|
||||
replace: true,
|
||||
): void;
|
||||
function setSynchronizedState(partial: GlobalSessionStatusStateUpdate, replace?: boolean): void {
|
||||
if (partial instanceof Function) {
|
||||
if (replace === true) {
|
||||
// SAFETY: Zustand's `replace: true` overload only accepts a complete state or a complete-state updater.
|
||||
storeSetState(partial as GlobalSessionStatusState | ((state: GlobalSessionStatusState) => GlobalSessionStatusState), true);
|
||||
} else {
|
||||
storeSetState(partial, replace);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (partial.statusById === undefined || partial.activeSessionIds) {
|
||||
if (replace === true) {
|
||||
// SAFETY: Zustand's `replace: true` overload only accepts a complete state or a complete-state updater.
|
||||
storeSetState(partial as GlobalSessionStatusState, true);
|
||||
} else {
|
||||
storeSetState(partial, replace);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStatusById = partial.statusById;
|
||||
const current = useGlobalSessionStatusStore.getState();
|
||||
const nextActiveSessionIds = new Set<string>();
|
||||
for (const [sessionId, entry] of nextStatusById) {
|
||||
if (entry.status.type === 'busy' || entry.status.type === 'retry') {
|
||||
nextActiveSessionIds.add(sessionId);
|
||||
}
|
||||
}
|
||||
const sameMembership = nextActiveSessionIds.size === current.activeSessionIds.size
|
||||
&& [...nextActiveSessionIds].every((sessionId) => current.activeSessionIds.has(sessionId));
|
||||
const nextState = {
|
||||
...current,
|
||||
...partial,
|
||||
activeSessionIds: sameMembership ? current.activeSessionIds : nextActiveSessionIds,
|
||||
};
|
||||
if (replace === true) storeSetState(nextState, true);
|
||||
else storeSetState(nextState, replace);
|
||||
}
|
||||
|
||||
useGlobalSessionStatusStore.setState = setSynchronizedState;
|
||||
|
||||
const normalizeStatusType = (type: string | undefined): ActiveStatusType | 'idle' => {
|
||||
if (type === 'busy') return 'busy';
|
||||
if (type === 'retry') return 'retry';
|
||||
return 'idle';
|
||||
@@ -55,12 +117,17 @@ const setStatus = (sessionId: string, directory: string, status: SessionStatus |
|
||||
if (!current) return state;
|
||||
const next = new Map(state.statusById);
|
||||
next.delete(sessionId);
|
||||
return { statusById: next };
|
||||
const nextActiveSessionIds = new Set(state.activeSessionIds);
|
||||
nextActiveSessionIds.delete(sessionId);
|
||||
return { statusById: next, activeSessionIds: nextActiveSessionIds };
|
||||
}
|
||||
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 };
|
||||
if (current) return { statusById: next };
|
||||
const nextActiveSessionIds = new Set(state.activeSessionIds);
|
||||
nextActiveSessionIds.add(sessionId);
|
||||
return { statusById: next, activeSessionIds: nextActiveSessionIds };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -70,13 +137,16 @@ const setStatus = (sessionId: string, directory: string, status: SessionStatus |
|
||||
export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => {
|
||||
switch (payload.type) {
|
||||
case 'session.status': {
|
||||
// SAFETY: OpenCode event properties for this event contain the optional session ID and status payload.
|
||||
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,
|
||||
type === 'idle' ? { type: 'idle' } : ( // SAFETY: the normalized discriminator is busy or retry.
|
||||
{ ...(props.status ?? {}), type } as SessionStatus
|
||||
),
|
||||
);
|
||||
observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active');
|
||||
// `retry` is still a running turn, so the elapsed counter keeps going.
|
||||
@@ -85,6 +155,7 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event)
|
||||
}
|
||||
case 'session.idle':
|
||||
case 'session.error': {
|
||||
// SAFETY: OpenCode terminal event properties contain the optional addressed session ID.
|
||||
const props = payload.properties as { sessionID?: string } | undefined;
|
||||
if (typeof props?.sessionID === 'string' && props.sessionID) {
|
||||
setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' });
|
||||
@@ -94,9 +165,11 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event)
|
||||
return;
|
||||
}
|
||||
case 'session.deleted': {
|
||||
// SAFETY: OpenCode deletion event properties identify the deleted session directly or through info.id.
|
||||
const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined;
|
||||
const sessionId = props?.sessionID ?? props?.info?.id;
|
||||
if (sessionId) {
|
||||
setStatus(sessionId, normalizeDirectory(directory), { type: 'idle' });
|
||||
removeSessionOrdering(sessionId);
|
||||
removeSessionActivityTiming(sessionId);
|
||||
}
|
||||
@@ -137,10 +210,25 @@ export const applyGlobalSessionStatusSnapshot = (
|
||||
useGlobalSessionStatusStore.setState((state) => {
|
||||
let changed = false;
|
||||
const next = new Map(state.statusById);
|
||||
let nextActiveSessionIds: Set<string> | null = null;
|
||||
const hasActiveSession = (sessionId: string): boolean => (
|
||||
(nextActiveSessionIds ?? state.activeSessionIds).has(sessionId)
|
||||
);
|
||||
const removeActiveSession = (sessionId: string): void => {
|
||||
if (!hasActiveSession(sessionId)) return;
|
||||
nextActiveSessionIds ??= new Set(state.activeSessionIds);
|
||||
nextActiveSessionIds.delete(sessionId);
|
||||
};
|
||||
const addActiveSession = (sessionId: string): void => {
|
||||
if (hasActiveSession(sessionId)) return;
|
||||
nextActiveSessionIds ??= new Set(state.activeSessionIds);
|
||||
nextActiveSessionIds.add(sessionId);
|
||||
};
|
||||
|
||||
for (const [sessionId, entry] of state.statusById) {
|
||||
if ((entry.directory === directory || known.has(sessionId)) && !(sessionId in raw)) {
|
||||
next.delete(sessionId);
|
||||
removeActiveSession(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -151,17 +239,23 @@ export const applyGlobalSessionStatusSnapshot = (
|
||||
if (type === 'idle') {
|
||||
if (current && (current.directory === directory || known.has(sessionId))) {
|
||||
next.delete(sessionId);
|
||||
removeActiveSession(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// SAFETY: normalizeStatusType has narrowed this snapshot entry to the SDK's busy/retry status discriminator.
|
||||
const normalizedStatus = { ...status, type } as SessionStatus;
|
||||
if (!current || current.directory !== directory || !statusesEqual(current.status, normalizedStatus)) {
|
||||
next.set(sessionId, { status: normalizedStatus, directory });
|
||||
if (!current) addActiveSession(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? { statusById: next } : state;
|
||||
return changed ? {
|
||||
statusById: next,
|
||||
activeSessionIds: nextActiveSessionIds ?? state.activeSessionIds,
|
||||
} : state;
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user