perf(ui): index and batch session updates

Batch global session, status, ordering, and activity timing mutations at the existing directory event boundary so large subagent bursts publish each owner once.

Maintain active session roots, children, and directory buckets in the global store, reuse them in Sidebar projections, and avoid rebuilding live aggregates and structural data for unrelated renders while preserving authoritative ordering reconciliation.
This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:37 +08:00
parent df924aa532
commit 701173e399
16 changed files with 1153 additions and 376 deletions
+4 -3
View File
@@ -18,7 +18,8 @@ There are **two distinct session data scopes** in the UI:
- Holds:
- global active sessions
- global archived sessions
- active sessions indexed by directory
- active and archived entities indexed by ID
- active root, parent/child, and directory indexes
These two scopes are intentionally different, but they are no longer equal peers for live UI truth.
@@ -47,7 +48,7 @@ So:
| `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime |
| `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime |
| `session-ui-store.ts` | Session selection, draft lifecycle, one-shot draft-materialization transition identity, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
| `useGlobalSessionsStore.ts` | Global active/archived entities plus root, parent/child, and directory indexes | All opened project/worktree session lists |
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes |
| `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments |
@@ -215,7 +216,7 @@ The profiler also emits a user-timing mark when pending global-session recency i
Streaming assistant and reasoning text is throttled once before reaching the markdown renderer. The renderer incrementally reconciles changed markdown blocks but does not add a second character-pacing timer, which would multiply parse/morph work while catching up on large streamed chunks.
The event pipeline delivers each ordered per-directory flush as one reducer batch. Events retain their individual global indexes, notifications, cleanup, routing, materialization, and debug side effects, while their directory mutations accumulate in order and publish one store transaction per touched directory. Each top-level state slice is cloned lazily at most once in that batch; no-op events do not change references.
The event pipeline delivers each ordered per-directory flush as one reducer batch. Events retain their individual notifications, cleanup, routing, materialization, and debug side effects, while directory mutations accumulate in order and publish one store transaction per touched directory. Global session mutations and live status, ordering, and timing transitions also accumulate in event order and each owner publishes at most once for the flush. Each top-level state slice is cloned lazily at most once in that batch; no-op events do not change references.
Streaming lifecycle derivation has two paths. Directory attach, switch, bootstrap, and reconnect may perform a full reconciliation. Normal store publications reconcile only sessions whose `session_status` or `message` bucket changed; part-only events update the affected streaming message heartbeat directly and must not rescan all busy sessions.
@@ -4,6 +4,7 @@ import type { Event, Session } from "@opencode-ai/sdk/v2/client"
let currentSessions: Session[] = []
const upsertedSessions: Session[] = []
const removedSessionIds: string[] = []
let mutationCalls = 0
let runtimeKey = "runtime-a"
let runtimeWillChange: (() => void) | null = null
@@ -15,6 +16,7 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
getState: () => ({
activeSessions: currentSessions,
archivedSessions: [] as Session[],
entityById: new Map(currentSessions.map((session) => [session.id, session])),
upsertSession: (session: Session) => {
upsertedSessions.push(session)
},
@@ -24,6 +26,15 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
removeSessions: (ids: string[]) => {
removedSessionIds.push(...ids)
},
applySessionMutations: (mutations: Array<
{ type: "upsert"; session: Session } | { type: "remove"; sessionId: string }
>) => {
mutationCalls += 1
for (const mutation of mutations) {
if (mutation.type === "upsert") upsertedSessions.push(mutation.session)
else removedSessionIds.push(mutation.sessionId)
}
},
}),
},
}))
@@ -34,7 +45,7 @@ mock.module("@/lib/runtime-switch", () => ({
return () => undefined
},
}))
import { applySessionEventToGlobalSessions } from "../session-event-router"
import { applySessionEventsToGlobalSessions, applySessionEventToGlobalSessions } from "../session-event-router"
const buildSession = (title: string, time: Session["time"]): Session => ({
id: "ses_1",
@@ -66,6 +77,7 @@ describe("applySessionEventToGlobalSessions", () => {
currentSessions = []
upsertedSessions.length = 0
removedSessionIds.length = 0
mutationCalls = 0
})
test("skips stale global session.updated echoes after a newer rename", () => {
@@ -116,4 +128,22 @@ describe("applySessionEventToGlobalSessions", () => {
expect(upsertedSessions).toEqual([])
})
test("commits an ordered event batch once", () => {
const events = Array.from({ length: 1_000 }, (_, index) => ({
type: "session.created",
properties: {
info: {
id: `ses_${index}`,
title: `Session ${index}`,
time: { created: index, updated: index },
},
},
} as Event))
applySessionEventsToGlobalSessions(events)
expect(mutationCalls).toBe(1)
expect(upsertedSessions).toHaveLength(1_000)
})
})
@@ -2,14 +2,17 @@ import { beforeEach, describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2/client"
import {
applyGlobalSessionStatusEvent,
applyGlobalSessionStatusEvents,
applyGlobalSessionStatusSnapshot,
useGlobalSessionStatusStore,
} from "./global-session-status"
import { resetSessionOrdering, useSessionOrderingStore } from "./session-ordering"
import { resetSessionActivityTiming, useSessionActivityTimingStore } from "./session-activity-timing"
beforeEach(() => {
useGlobalSessionStatusStore.setState({ statusById: new Map() })
resetSessionOrdering()
resetSessionActivityTiming()
})
describe("global session status index", () => {
@@ -171,4 +174,44 @@ describe("global session status index", () => {
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
})
test("publishes status, ordering, and timing once for a large event batch", () => {
let statusPublications = 0
let orderingPublications = 0
let timingPublications = 0
const unsubscribeStatus = useGlobalSessionStatusStore.subscribe(() => { statusPublications += 1 })
const unsubscribeOrdering = useSessionOrderingStore.subscribe(() => { orderingPublications += 1 })
const unsubscribeTiming = useSessionActivityTimingStore.subscribe(() => { timingPublications += 1 })
const events = Array.from({ length: 1_000 }, (_, index) => ({
type: "session.status",
properties: { sessionID: `session-${index}`, status: { type: "busy" } },
} as Event))
applyGlobalSessionStatusEvents("/repo", events)
unsubscribeStatus()
unsubscribeOrdering()
unsubscribeTiming()
expect(useGlobalSessionStatusStore.getState().activeSessionIds.size).toBe(1_000)
expect(statusPublications).toBe(1)
expect(orderingPublications).toBe(1)
expect(timingPublications).toBe(1)
})
test("keeps lifecycle event order inside a batch", () => {
applyGlobalSessionStatusEvents("/repo", [
{
type: "session.status",
properties: { sessionID: "session-a", status: { type: "busy" } },
} as Event,
{
type: "session.deleted",
properties: { sessionID: "session-a" },
} as Event,
])
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
expect(useSessionOrderingStore.getState().rankById.has("session-a")).toBe(false)
expect(useSessionActivityTimingStore.getState().startedAt.has("session-a")).toBe(false)
})
})
+68 -56
View File
@@ -2,15 +2,16 @@ import { create } from 'zustand';
import type { Event, SessionStatus } from '@opencode-ai/sdk/v2/client';
import { normalizeProjectPath } from '@/lib/projectResolution';
import {
observeSessionActivityEvent,
applySessionOrderingMutations,
reconcileSessionActivitySnapshot,
removeSessionOrdering,
type SessionOrderingMutation,
} from './session-ordering';
import {
observeSessionActivityTiming,
applySessionActivityTimingMutations,
reconcileSessionActivityTiming,
removeSessionActivityTiming,
type SessionActivityTimingMutation,
} from './session-activity-timing';
import { countSyncPerformance } from './performance-diagnostics';
// Shared live busy/retry index for every directory. Global events update it
// incrementally and authoritative directory snapshots reconcile it, so each
@@ -37,6 +38,7 @@ const initialState: GlobalSessionStatusState = {
};
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => initialState);
useGlobalSessionStatusStore.subscribe(() => countSyncPerformance('globalStatusPublications'));
// Runtime switching currently replaces statusById directly. Keep that boundary
// synchronized without making normal status mutations derive membership again.
@@ -110,74 +112,84 @@ const statusesEqual = (left: SessionStatus, right: SessionStatus): boolean => (
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);
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 });
if (current) return { statusById: next };
const nextActiveSessionIds = new Set(state.activeSessionIds);
nextActiveSessionIds.add(sessionId);
return { statusById: next, activeSessionIds: nextActiveSessionIds };
});
};
// 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': {
export const applyGlobalSessionStatusEvents = (directory: string, payloads: readonly Event[]): void => {
if (payloads.length === 0) return;
const normalizedDirectory = normalizeDirectory(directory);
const state = useGlobalSessionStatusStore.getState();
let statusById: Map<string, GlobalSessionStatusEntry> | null = null;
let activeSessionIds: Set<string> | null = null;
const orderingMutations: SessionOrderingMutation[] = [];
const timingMutations: SessionActivityTimingMutation[] = [];
const currentStatuses = (): ReadonlyMap<string, GlobalSessionStatusEntry> => statusById ?? state.statusById;
const draftStatuses = (): Map<string, GlobalSessionStatusEntry> => (statusById ??= new Map(state.statusById));
const draftActiveIds = (): Set<string> => (activeSessionIds ??= new Set(state.activeSessionIds));
const settle = (sessionId: string): void => {
if (currentStatuses().has(sessionId)) {
draftStatuses().delete(sessionId);
draftActiveIds().delete(sessionId);
}
orderingMutations.push({ type: 'observe', sessionId, phase: 'settled' });
timingMutations.push({ type: 'observe', sessionId, phase: 'settled' });
};
for (const payload of payloads) {
if (payload.type === '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;
if (typeof props?.sessionID !== 'string' || !props.sessionID) continue;
const type = normalizeStatusType(props.status?.type);
setStatus(
props.sessionID,
normalizeDirectory(directory),
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.
observeSessionActivityTiming(props.sessionID, type === 'idle' ? 'settled' : 'active');
return;
if (type === 'idle') {
settle(props.sessionID);
continue;
}
// SAFETY: the normalized discriminator is one of the SDK's active status types.
const status = { ...(props.status ?? {}), type } as SessionStatus;
const current = currentStatuses().get(props.sessionID);
if (!current || current.directory !== normalizedDirectory || !statusesEqual(current.status, status)) {
draftStatuses().set(props.sessionID, { status, directory: normalizedDirectory });
if (!current) draftActiveIds().add(props.sessionID);
}
orderingMutations.push({ type: 'observe', sessionId: props.sessionID, phase: 'active' });
timingMutations.push({ type: 'observe', sessionId: props.sessionID, phase: 'active' });
continue;
}
case 'session.idle':
case 'session.error': {
if (payload.type === 'session.idle' || payload.type === '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' });
observeSessionActivityEvent(props.sessionID, 'settled');
observeSessionActivityTiming(props.sessionID, 'settled');
}
return;
if (typeof props?.sessionID === 'string' && props.sessionID) settle(props.sessionID);
continue;
}
case 'session.deleted': {
if (payload.type === '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);
if (!sessionId) continue;
if (currentStatuses().has(sessionId)) {
draftStatuses().delete(sessionId);
draftActiveIds().delete(sessionId);
}
return;
orderingMutations.push({ type: 'remove', sessionId });
timingMutations.push({ type: 'remove', sessionId });
}
default:
return;
}
if (statusById) {
useGlobalSessionStatusStore.setState({
statusById,
activeSessionIds: activeSessionIds ?? state.activeSessionIds,
});
}
applySessionOrderingMutations(orderingMutations);
applySessionActivityTimingMutations(timingMutations);
};
export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => {
applyGlobalSessionStatusEvents(directory, [payload]);
};
// Polled path: an authoritative `/session/status?directory=X` snapshot. Entries
@@ -16,6 +16,15 @@ export type SyncPerformanceCounters = {
reducerEvents: number
reducerChangedEvents: number
directoryStorePublications: number
globalSessionPublications: number
globalStatusPublications: number
orderingPublications: number
timingPublications: number
liveSessionAggregateRuns: number
sidebarStructureBuilds: number
sidebarOrderBuilds: number
sidebarOrderMetadataEntries: number
recentCandidatesVisited: number
streamingFullReconciliations: number
streamingIncrementalReconciliations: number
streamingStatusEntriesVisited: number
@@ -50,6 +59,15 @@ const createCounters = (): SyncPerformanceCounters => ({
reducerEvents: 0,
reducerChangedEvents: 0,
directoryStorePublications: 0,
globalSessionPublications: 0,
globalStatusPublications: 0,
orderingPublications: 0,
timingPublications: 0,
liveSessionAggregateRuns: 0,
sidebarStructureBuilds: 0,
sidebarOrderBuilds: 0,
sidebarOrderMetadataEntries: 0,
recentCandidatesVisited: 0,
streamingFullReconciliations: 0,
streamingIncrementalReconciliations: 0,
streamingStatusEntriesVisited: 0,
+70 -31
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { create } from 'zustand';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { countSyncPerformance } from './performance-diagnostics';
// Per-session turn timing behind the sidebar activity readout.
//
@@ -50,6 +51,14 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
type SessionActivityPhase = 'active' | 'settled';
export type SessionActivityTimingMutation =
| { type: 'observe'; sessionId: string; phase: SessionActivityPhase }
| { type: 'remove'; sessionId: string };
type ActivityTimingDraft = {
startedAt: Map<string, number> | null;
settledMs: Map<string, number> | null;
};
type SessionActivityTimingState = {
startedAt: ReadonlyMap<string, number>;
@@ -87,13 +96,13 @@ const RESTORE_ADOPTION_WINDOW_MS = 90_000;
// page did not write to looks exactly like "no turn was running".
const STORAGE_KEY = 'oc.session-activity.v1';
const EMPTY_ACTIVE: ReadonlySet<string> = new Set();
const EMPTY_RESTORED: ReadonlyMap<string, PersistedStart> = new Map();
export const useSessionActivityTimingStore = create<SessionActivityTimingState>(() => ({
startedAt: new Map(),
settledMs: new Map(),
}));
useSessionActivityTimingStore.subscribe(() => countSyncPerformance('timingPublications'));
/** Last moment each live start was observed active, for the liveness stamp. */
const liveSeen = new Map<string, number>();
@@ -355,11 +364,66 @@ export const observeSessionActivityTiming = (
sessionId: string,
phase: SessionActivityPhase,
): void => {
if (phase === 'active') {
applyTransitions(new Set([sessionId]), null);
return;
applySessionActivityTimingMutations([{ type: 'observe', sessionId, phase }]);
};
export const applySessionActivityTimingMutations = (
mutations: readonly SessionActivityTimingMutation[],
): void => {
if (mutations.length === 0) return;
const now = Date.now();
const restored = getAdoptableStarts(now);
const state = useSessionActivityTimingStore.getState();
const next: ActivityTimingDraft = { startedAt: null, settledMs: null };
let restoredChanged = false;
let sawActive = false;
const currentStarted = (): ReadonlyMap<string, number> => next.startedAt ?? state.startedAt;
const currentSettled = (): ReadonlyMap<string, number> => next.settledMs ?? state.settledMs;
const draftStarted = (): Map<string, number> => (next.startedAt ??= new Map(state.startedAt));
const draftSettled = (): Map<string, number> => (next.settledMs ??= new Map(state.settledMs));
for (const mutation of mutations) {
if (mutation.type === 'remove') {
if (getRestoredStarts().delete(mutation.sessionId)) restoredChanged = true;
liveSeen.delete(mutation.sessionId);
if (currentStarted().has(mutation.sessionId)) draftStarted().delete(mutation.sessionId);
if (currentSettled().has(mutation.sessionId)) draftSettled().delete(mutation.sessionId);
continue;
}
if (mutation.phase === 'active') {
sawActive = true;
liveSeen.set(mutation.sessionId, now);
if (!currentStarted().has(mutation.sessionId)) {
draftStarted().set(mutation.sessionId, restored.get(mutation.sessionId)?.start ?? now);
}
if (currentSettled().has(mutation.sessionId)) draftSettled().delete(mutation.sessionId);
continue;
}
if (getRestoredStarts().delete(mutation.sessionId)) restoredChanged = true;
const start = currentStarted().get(mutation.sessionId);
if (start === undefined) continue;
draftStarted().delete(mutation.sessionId);
liveSeen.delete(mutation.sessionId);
draftSettled().set(mutation.sessionId, Math.max(0, now - start));
}
if (next.settledMs) trimSettled(next.settledMs);
if (next.startedAt || next.settledMs) {
useSessionActivityTimingStore.setState({
startedAt: next.startedAt ?? state.startedAt,
settledMs: next.settledMs ?? state.settledMs,
});
}
if (next.startedAt) {
if (next.startedAt.size > 0) ensureLivenessStampOnHide();
persistStarts(next.startedAt, now);
} else if (restoredChanged) {
persistStarts(state.startedAt, now);
} else if (sawActive && state.startedAt.size > 0 && now - lastPersistAt >= LIVENESS_PERSIST_INTERVAL_MS) {
persistStarts(state.startedAt, now);
}
applyTransitions(EMPTY_ACTIVE, { source: 'event', sessionId });
};
/**
@@ -377,32 +441,7 @@ export const reconcileSessionActivityTiming = (
};
export const removeSessionActivityTiming = (sessionId: string): void => {
const restoredChanged = getRestoredStarts().delete(sessionId);
const state = useSessionActivityTimingStore.getState();
const hadStart = state.startedAt.has(sessionId);
const hadSettled = state.settledMs.has(sessionId);
liveSeen.delete(sessionId);
if (!hadStart && !hadSettled) {
if (restoredChanged) persistStarts(state.startedAt, Date.now());
return;
}
let startedAt = state.startedAt;
if (hadStart) {
const draft = new Map(state.startedAt);
draft.delete(sessionId);
startedAt = draft;
}
let settledMs = state.settledMs;
if (hadSettled) {
const draft = new Map(state.settledMs);
draft.delete(sessionId);
settledMs = draft;
}
useSessionActivityTimingStore.setState({ startedAt, settledMs });
if (hadStart || restoredChanged) persistStarts(startedAt, Date.now());
applySessionActivityTimingMutations([{ type: 'remove', sessionId }]);
};
/**
+81 -62
View File
@@ -1,5 +1,10 @@
import type { Event, Session } from "@opencode-ai/sdk/v2/client"
import { isGlobalSessionRecencyOnlyUpdate, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import {
isGlobalSessionRecencyOnlyUpdate,
mergeSessionDirectoryMetadata,
useGlobalSessionsStore,
type GlobalSessionMutation,
} from "@/stores/useGlobalSessionsStore"
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
import { streamPerfCount, streamPerfMark } from "@/stores/utils/streamDebug"
import { stripSessionDiffSnapshots } from "./sanitize"
@@ -11,23 +16,6 @@ const clearPendingGlobalSessionUpdates = (): void => {
pendingGlobalSessionUpdates.clear()
}
const flushPendingGlobalSessionUpdate = (sessionID: string): void => {
const update = pendingGlobalSessionUpdates.get(sessionID)
pendingGlobalSessionUpdates.delete(sessionID)
if (!update) return
const runtimeKey = getRuntimeKey()
if (update.runtimeKey !== runtimeKey) return
const currentSession = getGlobalSessionSnapshot(update.session.id)
if (
!currentSession
|| shouldSkipStaleSessionEvent(currentSession, update.session)
|| !isGlobalSessionRecencyOnlyUpdate(currentSession, update.session)
) return
streamPerfMark("global_sessions.event_update_flush")
useGlobalSessionsStore.getState().upsertSession(update.session)
streamPerfCount("ui.global_sessions.event_update_publication")
}
const scheduleGlobalSessionUpdate = (session: Session): void => {
pendingGlobalSessionUpdates.set(session.id, { runtimeKey: getRuntimeKey(), session })
streamPerfCount("ui.global_sessions.event_update_deferred")
@@ -58,51 +46,82 @@ const getSessionInfoFromPayload = (event: Event): Session | null => {
return stripSessionDiffSnapshots(session as Session)
}
const getGlobalSessionSnapshot = (sessionId: string): Session | null => {
const global = useGlobalSessionsStore.getState()
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
export const applySessionEventsToGlobalSessions = (payloads: readonly Event[]): void => {
if (payloads.length === 0) return
const runtimeKey = getRuntimeKey()
const store = useGlobalSessionsStore.getState()
const overlay = new Map(store.entityById)
const mutations: GlobalSessionMutation[] = []
let flushedRecency = false
const appendUpsert = (session: Session): void => {
const existing = overlay.get(session.id) ?? null
const merged = mergeSessionDirectoryMetadata(session, existing)
overlay.set(session.id, merged)
mutations.push({ type: "upsert", session: merged })
}
for (const payload of payloads) {
if (payload.type === "session.idle" || payload.type === "session.error") {
const sessionID = (payload as { properties?: { sessionID?: unknown } }).properties?.sessionID
if (typeof sessionID !== "string") continue
const update = pendingGlobalSessionUpdates.get(sessionID)
pendingGlobalSessionUpdates.delete(sessionID)
if (!update || update.runtimeKey !== runtimeKey) continue
const currentSession = overlay.get(sessionID) ?? null
if (
!currentSession
|| shouldSkipStaleSessionEvent(currentSession, update.session)
|| !isGlobalSessionRecencyOnlyUpdate(currentSession, update.session)
) continue
appendUpsert(update.session)
flushedRecency = true
continue
}
if (payload.type === "session.created") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = overlay.get(session.id) ?? null
if (!shouldSkipStaleSessionEvent(currentSession, session)) appendUpsert(session)
}
continue
}
if (payload.type === "session.updated") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = overlay.get(session.id) ?? null
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
if (currentSession && isGlobalSessionRecencyOnlyUpdate(currentSession, session)) {
scheduleGlobalSessionUpdate(session)
} else {
pendingGlobalSessionUpdates.delete(session.id)
appendUpsert(session)
streamPerfCount("ui.global_sessions.event_update_immediate")
}
}
}
continue
}
if (payload.type === "session.deleted") {
const sessionID = (payload as { properties?: { sessionID?: string } }).properties?.sessionID
?? getSessionInfoFromPayload(payload)?.id
if (sessionID) {
pendingGlobalSessionUpdates.delete(sessionID)
overlay.delete(sessionID)
mutations.push({ type: "remove", sessionId: sessionID })
}
}
}
if (mutations.length === 0 || runtimeKey !== getRuntimeKey()) return
if (flushedRecency) streamPerfMark("global_sessions.event_update_flush")
store.applySessionMutations(mutations)
streamPerfCount("ui.global_sessions.event_update_publication")
}
export const applySessionEventToGlobalSessions = (payload: Event): void => {
if (payload.type === "session.idle" || payload.type === "session.error") {
const sessionID = (payload as { properties?: { sessionID?: unknown } }).properties?.sessionID
if (typeof sessionID === "string") flushPendingGlobalSessionUpdate(sessionID)
return
}
if (payload.type === "session.created") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = getGlobalSessionSnapshot(session.id)
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
useGlobalSessionsStore.getState().upsertSession(session)
}
}
return
}
if (payload.type === "session.updated") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = getGlobalSessionSnapshot(session.id)
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
if (currentSession && isGlobalSessionRecencyOnlyUpdate(currentSession, session)) {
scheduleGlobalSessionUpdate(session)
} else {
pendingGlobalSessionUpdates.delete(session.id)
useGlobalSessionsStore.getState().upsertSession(session)
streamPerfCount("ui.global_sessions.event_update_immediate")
}
}
}
return
}
if (payload.type === "session.deleted") {
const sessionID = (payload as { properties?: { sessionID?: string } }).properties?.sessionID ?? getSessionInfoFromPayload(payload)?.id
if (sessionID) {
pendingGlobalSessionUpdates.delete(sessionID)
useGlobalSessionsStore.getState().removeSessions([sessionID])
}
}
applySessionEventsToGlobalSessions([payload])
}
+93 -19
View File
@@ -1,9 +1,13 @@
import { create } from 'zustand';
import type { Session } from '@opencode-ai/sdk/v2';
import { isSessionPinned } from '@/stores/useSessionPinnedStore';
import { normalizePath } from '@/lib/pathNormalization';
import { countSyncPerformance } from './performance-diagnostics';
type SessionActivityPhase = 'active' | 'settled';
export type SessionActivityPhase = 'active' | 'settled';
export type SessionOrderingMutation =
| { type: 'observe'; sessionId: string; phase: SessionActivityPhase }
| { type: 'remove'; sessionId: string };
type SessionOrderingState = {
rankById: Map<string, number>;
@@ -18,6 +22,7 @@ let lastRank = 0;
export const useSessionOrderingStore = create<SessionOrderingState>(() => ({
rankById: new Map(),
}));
useSessionOrderingStore.subscribe(() => countSyncPerformance('orderingPublications'));
const nextRank = (): number => {
lastRank = Math.max(lastRank + 1, Date.now());
@@ -42,12 +47,36 @@ export const observeSessionActivityEvent = (
sessionId: string,
phase: SessionActivityPhase,
): void => {
const previous = phaseById.get(sessionId);
phaseById.set(sessionId, phase);
applySessionOrderingMutations([{ type: 'observe', sessionId, phase }]);
};
if (previous === phase) return;
if (previous === undefined && phase === 'settled') return;
promoteSessions([sessionId]);
export const applySessionOrderingMutations = (
mutations: readonly SessionOrderingMutation[],
): void => {
if (mutations.length === 0) return;
const currentRanks = useSessionOrderingStore.getState().rankById;
let rankById: Map<string, number> | null = null;
for (const mutation of mutations) {
if (mutation.type === 'remove') {
phaseById.delete(mutation.sessionId);
baselineRankById.delete(mutation.sessionId);
if ((rankById ?? currentRanks).has(mutation.sessionId)) {
rankById ??= new Map(currentRanks);
rankById.delete(mutation.sessionId);
}
continue;
}
const previous = phaseById.get(mutation.sessionId);
phaseById.set(mutation.sessionId, mutation.phase);
if (previous === mutation.phase) continue;
if (previous === undefined && mutation.phase === 'settled') continue;
rankById ??= new Map(currentRanks);
rankById.set(mutation.sessionId, nextRank());
}
if (rankById) useSessionOrderingStore.setState({ rankById });
};
export const reconcileSessionActivitySnapshot = (
@@ -71,14 +100,7 @@ export const reconcileSessionActivitySnapshot = (
};
export const removeSessionOrdering = (sessionId: string): void => {
phaseById.delete(sessionId);
baselineRankById.delete(sessionId);
useSessionOrderingStore.setState((state) => {
if (!state.rankById.has(sessionId)) return state;
const rankById = new Map(state.rankById);
rankById.delete(sessionId);
return { rankById };
});
applySessionOrderingMutations([{ type: 'remove', sessionId }]);
};
export const resetSessionOrdering = (): void => {
@@ -107,7 +129,7 @@ const sessionDirectory = (session: Session): string | null => {
directory?: string | null;
project?: { worktree?: string | null } | null;
};
return normalizePath(record.directory ?? null) ?? normalizePath(record.project?.worktree ?? null);
return record.directory ?? record.project?.worktree ?? null;
};
const baselineRank = (session: Session, pinned: boolean): number => {
@@ -197,12 +219,39 @@ export const orderSessionsByLifecycleScopes = (
sessions: Session[],
pinnedSessionIds: Set<string>,
rankById: ReadonlyMap<string, number>,
hierarchy?: {
rootIds: readonly string[];
childrenByParentId: ReadonlyMap<string, readonly string[]>;
},
): Session[] => {
countSyncPerformance('sidebarOrderBuilds');
const sessionIds = new Set(sessions.map((session) => session.id));
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const roots: Session[] = [];
const childrenByParent = new Map<string, Session[]>();
const indexedIds = new Set<string>();
if (hierarchy) {
for (const sessionId of hierarchy.rootIds) {
const session = sessionById.get(sessionId);
if (!session) continue;
indexedIds.add(sessionId);
roots.push(session);
}
for (const [parentId, childIds] of hierarchy.childrenByParentId) {
if (!sessionIds.has(parentId)) continue;
const children = childIds.flatMap((sessionId) => {
const session = sessionById.get(sessionId);
if (!session) return [];
indexedIds.add(sessionId);
return [session];
});
if (children.length > 0) childrenByParent.set(parentId, children);
}
}
for (const session of sessions) {
if (indexedIds.has(session.id)) continue;
const parentId = parentIdOf(session);
if (!parentId || !sessionIds.has(parentId)) {
roots.push(session);
@@ -217,9 +266,34 @@ export const orderSessionsByLifecycleScopes = (
}
}
const compare = (left: Session, right: Session) => (
compareSessionsByLifecycleOrder(left, right, pinnedSessionIds, rankById)
);
const metadataById = new Map(sessions.map((session) => {
const parentId = parentIdOf(session);
const pinned = isSessionPinned(pinnedSessionIds, sessionDirectory(session), session.id);
const fallback = baselineRank(session, pinned);
return [session.id, {
parentId,
pinned,
fallback,
lifecycle: rankById.get(session.id) ?? fallback,
created: baselineRank(session, true),
}] as const;
}));
countSyncPerformance('sidebarOrderMetadataEntries', metadataById.size);
const compare = (left: Session, right: Session): number => {
const leftMetadata = metadataById.get(left.id);
const rightMetadata = metadataById.get(right.id);
if (!leftMetadata || !rightMetadata) return left.id.localeCompare(right.id);
if (leftMetadata.pinned !== rightMetadata.pinned) return leftMetadata.pinned ? -1 : 1;
if (leftMetadata.parentId === rightMetadata.parentId) {
const rankDelta = rightMetadata.lifecycle - leftMetadata.lifecycle;
if (rankDelta !== 0) return rankDelta;
}
const baselineDelta = rightMetadata.fallback - leftMetadata.fallback;
if (baselineDelta !== 0) return baselineDelta;
const createdDelta = rightMetadata.created - leftMetadata.created;
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
};
roots.sort(compare);
for (const siblings of childrenByParent.values()) {
siblings.sort(compare);
+74 -23
View File
@@ -38,7 +38,7 @@ import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
import { useSessionUIStore } from "./session-ui-store"
import { stripSessionDiffSnapshots } from "./sanitize"
import { upsertSessionRecord } from "./session-records"
import { applySessionEventToGlobalSessions } from "./session-event-router"
import { applySessionEventToGlobalSessions, applySessionEventsToGlobalSessions } from "./session-event-router"
import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
import { messagesBefore } from "./message-ordering"
@@ -53,7 +53,12 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { toast } from "@/components/ui"
import { appendNotification } from "./notification-store"
import { applyGlobalSessionStatusEvent, applyGlobalSessionStatusSnapshot, useGlobalSessionStatusStore } from "./global-session-status"
import {
applyGlobalSessionStatusEvent,
applyGlobalSessionStatusEvents,
applyGlobalSessionStatusSnapshot,
useGlobalSessionStatusStore,
} from "./global-session-status"
import type { State } from "./types"
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
import type { PermissionRequest } from "@/types/permission"
@@ -161,25 +166,42 @@ function useLiveSyncSelector<T>(
subscribe?: (childStores: ChildStoreManager, notify: () => void) => () => void,
): T {
const { childStores } = useSyncSystem()
const cacheRef = useRef<T | undefined>(undefined)
const initializedRef = useRef(false)
const sourceRevisionRef = useRef(0)
const cacheRef = useRef<{
childStores: ChildStoreManager
selector: (states: State[]) => T
revision: number
value: T
} | null>(null)
const getSnapshot = useCallback(() => {
const next = selector(getLiveStates(childStores))
if (initializedRef.current && isEqual(cacheRef.current as T, next)) {
return cacheRef.current as T
const cached = cacheRef.current
if (
cached
&& cached.childStores === childStores
&& cached.selector === selector
&& cached.revision === sourceRevisionRef.current
) {
return cached.value
}
cacheRef.current = next
initializedRef.current = true
return next
const next = selector(getLiveStates(childStores))
const value = cached && isEqual(cached.value, next) ? cached.value : next
cacheRef.current = { childStores, selector, revision: sourceRevisionRef.current, value }
return value
}, [childStores, isEqual, selector])
const subscribeToSource = useCallback((notify: () => void) => {
const invalidate = () => {
sourceRevisionRef.current += 1
notify()
}
// Force the post-subscribe snapshot to close the read-before-subscribe gap.
sourceRevisionRef.current += 1
return subscribe ? subscribe(childStores, invalidate) : childStores.subscribeAll(invalidate)
}, [childStores, subscribe])
return React.useSyncExternalStore(
useCallback(
(notify) => subscribe ? subscribe(childStores, notify) : childStores.subscribeAll(notify),
[childStores, subscribe],
),
subscribeToSource,
getSnapshot,
getSnapshot,
)
@@ -195,12 +217,16 @@ type DirectoryEventBatch = {
states: Map<StoreApi<DirectoryStore>, DirectoryStore>
clonedFields: Map<StoreApi<DirectoryStore>, Set<keyof State>>
changedStores: Set<StoreApi<DirectoryStore>>
globalSessionEvents: Event[]
globalStatusEventsByDirectory: Map<string, Event[]>
}
const createDirectoryEventBatch = (): DirectoryEventBatch => ({
states: new Map(),
clonedFields: new Map(),
changedStores: new Set(),
globalSessionEvents: [],
globalStatusEventsByDirectory: new Map(),
})
const getDirectoryEventState = (
@@ -209,6 +235,10 @@ const getDirectoryEventState = (
): DirectoryStore => batch?.states.get(store) ?? store.getState()
const publishDirectoryEventBatch = (batch: DirectoryEventBatch): void => {
applySessionEventsToGlobalSessions(batch.globalSessionEvents)
for (const [directory, events] of batch.globalStatusEventsByDirectory) {
applyGlobalSessionStatusEvents(directory, events)
}
for (const store of batch.changedStores) {
const state = batch.states.get(store)
if (!state) continue
@@ -241,7 +271,10 @@ export function useAllSessionStatuses(): Record<string, SessionStatus> {
export function useAllLiveSessions(): Session[] {
return useLiveSyncSelector(
useCallback((states) => aggregateLiveSessions(states), []),
useCallback((states) => {
countSyncPerformance("liveSessionAggregateRuns")
return aggregateLiveSessions(states)
}, []),
areSessionListsEquivalent,
useCallback(
(childStores: ChildStoreManager, notify: () => void) => childStores.subscribeAllSelected(
@@ -1454,6 +1487,7 @@ export function handleEvent(
skipVSCodeAutoAccept = false,
streamingDirectory?: string,
batch?: DirectoryEventBatch,
globalEffectsAlreadyApplied = false,
) {
if ((payload as { type?: unknown }).type === "openchamber:permission-auto-accept.updated") {
const properties = (payload as unknown as { properties?: unknown }).properties
@@ -1482,12 +1516,19 @@ export function handleEvent(
return
}
applySessionEventToGlobalSessions(payload)
// Keep the cross-project status map current for ALL directories (mirrors the
// global-session handling above). Child stores remain the primary source for
// synced directories; this map covers sessions a child store doesn't list
// (unopened directories, or list/status races for just-created sessions).
applyGlobalSessionStatusEvent(directory, payload)
if (!globalEffectsAlreadyApplied) {
if (batch) {
batch.globalSessionEvents.push(payload)
const statusEvents = batch.globalStatusEventsByDirectory.get(directory)
if (statusEvents) statusEvents.push(payload)
else batch.globalStatusEventsByDirectory.set(directory, [payload])
} else {
applySessionEventToGlobalSessions(payload)
// Child stores remain the primary source for synced directories; this
// index covers unopened directories and list/status races.
applyGlobalSessionStatusEvent(directory, payload)
}
}
// Global events
if (directory === "global" || !directory) {
@@ -1570,7 +1611,17 @@ export function handleEvent(
if (eventKey && pendingVSCodePermissionEvents.get(eventKey) !== eventToken) return
if (eventKey) pendingVSCodePermissionEvents.delete(eventKey)
if (expectedRuntimeKey !== getRuntimeKey()) return
if (!accepted) handleEvent(rawDirectory, payload, childStores, routingIndex, expectedRuntimeKey, true, streamingDirectory)
if (!accepted) handleEvent(
rawDirectory,
payload,
childStores,
routingIndex,
expectedRuntimeKey,
true,
streamingDirectory,
undefined,
true,
)
}
void processVSCodePermissionAutoAccept(permission, resolvedDirectory).then(
completePermissionCheck,