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:
@@ -142,7 +142,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
isVSCode: topology.isVSCode,
|
||||
pinnedSessionIds: collection.pinnedSessionIds,
|
||||
sessionOrderRanks: collection.sessionOrderRanks,
|
||||
sessions: collection.sessions,
|
||||
sessions: collection.rootSessions,
|
||||
});
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
@@ -268,6 +268,16 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
}),
|
||||
[getOrderedGroups, sectionsForSidebarRender],
|
||||
);
|
||||
let selectedSingleProjectId: string | null = null;
|
||||
if (singleProjectMode) {
|
||||
if (projectSections.some((section) => section.project.id === singleProjectId)) {
|
||||
selectedSingleProjectId = singleProjectId;
|
||||
} else if (projectSections.some((section) => section.project.id === view.activeProjectId)) {
|
||||
selectedSingleProjectId = view.activeProjectId;
|
||||
} else {
|
||||
selectedSingleProjectId = projectSections[0]?.project.id ?? null;
|
||||
}
|
||||
}
|
||||
const groupProps = React.useMemo(() => ({
|
||||
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||
@@ -401,7 +411,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
hasSessionSearchQuery={view.hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={view.normalizedSessionSearchQuery}
|
||||
isDesktopShellRuntime={view.isDesktopShellRuntime}
|
||||
sessions={collection.sessions}
|
||||
sessions={recentSessions}
|
||||
childrenMap={collection.childrenMap}
|
||||
pinnedSessionIds={collection.pinnedSessionIds}
|
||||
recentSessions={recentSessions}
|
||||
@@ -436,7 +446,6 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
alwaysShowActions,
|
||||
collection.childrenMap,
|
||||
collection.pinnedSessionIds,
|
||||
collection.sessions,
|
||||
copiedSessionId,
|
||||
deleteSessionConfirm,
|
||||
editTitle,
|
||||
@@ -470,13 +479,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
projectSections,
|
||||
activeProjectId: view.activeProjectId,
|
||||
singleProjectMode,
|
||||
singleProjectId: singleProjectMode
|
||||
? (projectSections.some((section) => section.project.id === singleProjectId)
|
||||
? singleProjectId
|
||||
: (projectSections.some((section) => section.project.id === view.activeProjectId)
|
||||
? view.activeProjectId
|
||||
: projectSections[0]?.project.id ?? null))
|
||||
: null,
|
||||
singleProjectId: selectedSingleProjectId,
|
||||
emptyState: view.emptyState,
|
||||
searchEmptyState: view.searchEmptyState,
|
||||
projectRepoStatus: topology.projectRepoStatus,
|
||||
@@ -497,8 +500,8 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
view.searchEmptyState,
|
||||
visibleSessionCountByGroup,
|
||||
recentSection,
|
||||
singleProjectId,
|
||||
singleProjectMode,
|
||||
selectedSingleProjectId,
|
||||
]);
|
||||
const scrollerView = React.useMemo(() => ({
|
||||
homeDirectory: view.homeDirectory,
|
||||
|
||||
@@ -238,6 +238,7 @@ describe('projectSidebarCollection', () => {
|
||||
expect(projection.orderedSessions.map((entry) => entry.id)).toEqual(['managed-root', 'managed-child', 'project-root']);
|
||||
expect(projection.childrenMap.get('managed-root')?.map((entry) => entry.id)).toEqual(['managed-child']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('useRecentSessionCollection', () => {
|
||||
@@ -299,6 +300,7 @@ describe('useRecentSessionCollection', () => {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getDescendantIds', () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useAllLiveSessions } from '@/sync/sync-context';
|
||||
import {
|
||||
compareSessionsByLifecycleOrder,
|
||||
EMPTY_SESSION_ORDER_RANKS,
|
||||
orderSessionsByLifecycleScopes,
|
||||
useSessionOrderingStore,
|
||||
@@ -15,6 +14,8 @@ import { deriveRecentSessions } from '../recent/activitySections';
|
||||
import { normalizePath } from '../utils';
|
||||
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { isBtwSession } from '@/lib/sessionBtwMetadata';
|
||||
import type { GlobalSessionStructure } from '@/stores/globalSessionStructure';
|
||||
import { countSyncPerformance } from '@/sync/performance-diagnostics';
|
||||
|
||||
type ProjectSidebarActiveSessionsArgs = {
|
||||
globalActiveSessions: Session[];
|
||||
@@ -28,6 +29,11 @@ type SidebarSessionPartitions = {
|
||||
chatSessions: Session[];
|
||||
};
|
||||
|
||||
const parentIdOf = (session: Session): string | null => {
|
||||
// SAFETY: OpenCode session payloads expose parentID although the SDK base Session omits it.
|
||||
return (session as Session & { parentID?: string | null }).parentID ?? null;
|
||||
};
|
||||
|
||||
// This boundary owns session visibility before Recent or projects take
|
||||
// ownership. Temporary /btw forks never leak into any sidebar projection.
|
||||
export const partitionSidebarSessions = (
|
||||
@@ -125,6 +131,78 @@ type SidebarSessionProjectionArgs = ProjectSidebarActiveSessionsArgs & {
|
||||
sessionOrderRanks: ReadonlyMap<string, number>;
|
||||
};
|
||||
|
||||
type SidebarSessionStructureArgs = Omit<ProjectSidebarActiveSessionsArgs, 'globalActiveSessions'> & {
|
||||
globalActiveSessions?: readonly Session[];
|
||||
globalStructure?: GlobalSessionStructure;
|
||||
};
|
||||
|
||||
const buildSidebarSessionStructure = ({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
globalStructure,
|
||||
}: SidebarSessionStructureArgs) => {
|
||||
countSyncPerformance('sidebarStructureBuilds');
|
||||
const indexedGlobalSessions = globalActiveSessions ?? [];
|
||||
const visibleSessions = mergeSidebarSessionSources(indexedGlobalSessions, liveSessions);
|
||||
const partition = partitionSidebarSessions(visibleSessions, isVSCode);
|
||||
const projectSessions = partition.projectSessions
|
||||
.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
||||
const sessions = [...projectSessions, ...partition.chatSessions];
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
const projectSessionIds = new Set(projectSessions.map((session) => session.id));
|
||||
const indexedRootIds = globalStructure?.activeRootIds ?? [];
|
||||
const indexedRootIdSet = new Set(indexedRootIds);
|
||||
const rootSessions = [
|
||||
...indexedRootIds.flatMap((sessionId) => {
|
||||
if (!projectSessionIds.has(sessionId)) return [];
|
||||
const session = sessionById.get(sessionId);
|
||||
return session ? [session] : [];
|
||||
}),
|
||||
...projectSessions.filter((session) => (
|
||||
!indexedRootIdSet.has(session.id) && !parentIdOf(session)
|
||||
)),
|
||||
];
|
||||
return {
|
||||
chatSessionIds: new Set(partition.chatSessions.map((session) => session.id)),
|
||||
projectSessions,
|
||||
rootSessions,
|
||||
sessionById,
|
||||
sessions,
|
||||
hierarchy: globalStructure ? {
|
||||
rootIds: globalStructure.activeRootIds,
|
||||
childrenByParentId: globalStructure.activeChildrenByParentId,
|
||||
} : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const orderSidebarSessionStructure = (
|
||||
structure: ReturnType<typeof buildSidebarSessionStructure>,
|
||||
pinnedSessionIds: Set<string>,
|
||||
sessionOrderRanks: ReadonlyMap<string, number>,
|
||||
) => {
|
||||
const orderedSessions = orderSessionsByLifecycleScopes(
|
||||
structure.sessions,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
structure.hierarchy,
|
||||
);
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
for (const session of orderedSessions) {
|
||||
const parentID = parentIdOf(session);
|
||||
if (!parentID) continue;
|
||||
const siblings = childrenMap.get(parentID) ?? [];
|
||||
siblings.push(session);
|
||||
childrenMap.set(parentID, siblings);
|
||||
}
|
||||
return {
|
||||
chatSessions: orderedSessions.filter((session) => structure.chatSessionIds.has(session.id)),
|
||||
childrenMap,
|
||||
orderedSessions,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSidebarSessionProjection = ({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
@@ -133,33 +211,17 @@ export const buildSidebarSessionProjection = ({
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
}: SidebarSessionProjectionArgs) => {
|
||||
const visibleSessions = mergeSidebarSessionSources(globalActiveSessions, liveSessions);
|
||||
const partition = partitionSidebarSessions(visibleSessions, isVSCode);
|
||||
const projectSessions = partition.projectSessions
|
||||
.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
||||
const orderedSessions = orderSessionsByLifecycleScopes(
|
||||
[...projectSessions, ...partition.chatSessions],
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
);
|
||||
const chatSessionIds = new Set(partition.chatSessions.map((session) => session.id));
|
||||
const sessionById = new Map(orderedSessions.map((session) => [session.id, session]));
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
for (const session of sessionById.values()) {
|
||||
// SAFETY: OpenCode's session records carry parentID for sub-session
|
||||
// hierarchy; the SDK's base Session type does not currently expose it.
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) continue;
|
||||
const siblings = childrenMap.get(parentID) ?? [];
|
||||
siblings.push(session);
|
||||
childrenMap.set(parentID, siblings);
|
||||
}
|
||||
const structure = buildSidebarSessionStructure({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
});
|
||||
const ordering = orderSidebarSessionStructure(structure, pinnedSessionIds, sessionOrderRanks);
|
||||
return {
|
||||
chatSessions: orderedSessions.filter((session) => chatSessionIds.has(session.id)),
|
||||
childrenMap,
|
||||
orderedSessions,
|
||||
projectSessions,
|
||||
sessionById,
|
||||
...ordering,
|
||||
projectSessions: structure.projectSessions,
|
||||
sessionById: structure.sessionById,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -178,6 +240,7 @@ export const useSessionProjectCollection = ({
|
||||
isVisible,
|
||||
}: UseSessionProjectCollectionArgs) => {
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const globalStructure = useGlobalSessionsStore((state) => state.structure);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||
const liveSessions = useAllLiveSessions();
|
||||
@@ -186,21 +249,25 @@ export const useSessionProjectCollection = ({
|
||||
(state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS,
|
||||
[isVisible],
|
||||
));
|
||||
const projection = React.useMemo(() => buildSidebarSessionProjection({
|
||||
const structure = React.useMemo(() => buildSidebarSessionStructure({
|
||||
globalActiveSessions,
|
||||
globalStructure,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
}), [globalActiveSessions, isVSCode, knownDirectories, liveSessions, pinnedSessionIds, sessionOrderRanks]);
|
||||
const { chatSessions, orderedSessions, projectSessions: sessions } = projection;
|
||||
}), [globalActiveSessions, globalStructure, isVSCode, knownDirectories, liveSessions]);
|
||||
const ordering = React.useMemo(
|
||||
() => orderSidebarSessionStructure(structure, pinnedSessionIds, sessionOrderRanks),
|
||||
[pinnedSessionIds, sessionOrderRanks, structure],
|
||||
);
|
||||
const { chatSessions, orderedSessions } = ordering;
|
||||
const sessions = structure.projectSessions;
|
||||
const sessionById = React.useMemo(() => new Map(
|
||||
[...orderedSessions, ...archivedSessions].map((session) => [session.id, session]),
|
||||
), [archivedSessions, orderedSessions]);
|
||||
[...structure.sessions, ...archivedSessions].map((session) => [session.id, session]),
|
||||
), [archivedSessions, structure.sessions]);
|
||||
const childrenMap = React.useMemo(() => {
|
||||
const children = new Map<string, Session[]>();
|
||||
for (const session of sessionById.values()) {
|
||||
const children = new Map(ordering.childrenMap);
|
||||
for (const session of archivedSessions) {
|
||||
// SAFETY: OpenCode's session records carry parentID for sub-session
|
||||
// hierarchy; the SDK's base Session type does not currently expose it.
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
@@ -210,7 +277,7 @@ export const useSessionProjectCollection = ({
|
||||
children.set(parentID, siblings);
|
||||
}
|
||||
return children;
|
||||
}, [sessionById]);
|
||||
}, [archivedSessions, ordering.childrenMap]);
|
||||
const getDescendantIdsForAction = React.useCallback(
|
||||
(sessionId: string, options: { includeArchived: boolean }) => getDescendantIds(childrenMap, sessionId)
|
||||
.filter((id) => options.includeArchived || !sessionById.get(id)?.time?.archived),
|
||||
@@ -222,13 +289,13 @@ export const useSessionProjectCollection = ({
|
||||
childrenMap,
|
||||
chatSessions,
|
||||
getDescendantIds: getDescendantIdsForAction,
|
||||
globalActiveSessions,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
liveSessions,
|
||||
orderedSessions,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
sessions,
|
||||
rootSessions: structure.rootSessions,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -258,7 +325,11 @@ export const useRecentSessionCollection = ({
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (!enabled || isVSCode) return [];
|
||||
return deriveRecentSessions(sessions, activeSessionIdSet)
|
||||
.sort((left, right) => compareSessionsByLifecycleOrder(left, right, pinnedSessionIds, sessionOrderRanks));
|
||||
countSyncPerformance('recentCandidatesVisited', sessions.length);
|
||||
return orderSessionsByLifecycleScopes(
|
||||
deriveRecentSessions(sessions, activeSessionIdSet),
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
);
|
||||
}, [activeSessionIdSet, enabled, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions]);
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ These stores coordinate persistent project/session metadata across multiple view
|
||||
|
||||
`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message. Desktop queues use the configured host id as runtime identity, not the current API URL, because an SSH reconnect allocates a new local forwarding port while the remote host remains the same.
|
||||
|
||||
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
|
||||
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage. Its entity map and active root, parent/child, and directory indexes are maintained in the same transaction as the compatibility arrays and `sessionsByDirectory`. Full authoritative snapshots may rebuild those indexes once; direct create, update, move, archive, and delete mutations update only affected hierarchy and directory buckets. Metadata-only updates preserve the structure reference. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
|
||||
|
||||
User-visible session ordering is also not owned by the global cache array order. `sync/session-ordering.ts` combines lifecycle rank with timestamp fallbacks, and session surfaces must use that shared comparator instead of independently sorting global sessions by `time.updated`.
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
|
||||
export type GlobalSessionStructure = {
|
||||
activeSessionIds: readonly string[];
|
||||
activeRootIds: readonly string[];
|
||||
activeChildrenByParentId: ReadonlyMap<string, readonly string[]>;
|
||||
activeIdsByDirectory: ReadonlyMap<string, readonly string[]>;
|
||||
};
|
||||
|
||||
export type GlobalSessionStructureMutation = {
|
||||
sessionId: string;
|
||||
previous: Session | null;
|
||||
next: Session | null;
|
||||
};
|
||||
|
||||
type SessionLocation = {
|
||||
directory: string | null;
|
||||
parentId: string | null;
|
||||
};
|
||||
|
||||
type BucketChange = {
|
||||
additions: Set<string>;
|
||||
removals: Set<string>;
|
||||
};
|
||||
|
||||
type SessionIndexFields = {
|
||||
directory?: string | null;
|
||||
parentID?: string | null;
|
||||
project?: { worktree?: string | null } | null;
|
||||
};
|
||||
|
||||
const indexFields = (session: Session): Session & SessionIndexFields => {
|
||||
// SAFETY: OpenCode session payloads expose these stable fields even though the SDK base Session omits them.
|
||||
return session as Session & SessionIndexFields;
|
||||
};
|
||||
|
||||
const parentIdOf = (session: Session): string | null => (
|
||||
indexFields(session).parentID ?? null
|
||||
);
|
||||
|
||||
export const resolveGlobalSessionDirectory = (session: Session): string | null => {
|
||||
const record = indexFields(session);
|
||||
|
||||
return normalizePath(record.directory ?? null)
|
||||
?? normalizePath(record.project?.worktree ?? null);
|
||||
};
|
||||
|
||||
export const mergeSessionDirectoryMetadata = (incoming: Session, existing?: Session | null): Session => {
|
||||
if (!existing) return incoming;
|
||||
|
||||
const incomingRecord = indexFields(incoming);
|
||||
const existingRecord = indexFields(existing);
|
||||
const incomingDirectory = normalizePath(incomingRecord.directory ?? null);
|
||||
const incomingWorktree = normalizePath(incomingRecord.project?.worktree ?? null);
|
||||
const existingDirectory = normalizePath(existingRecord.directory ?? null);
|
||||
const existingWorktree = normalizePath(existingRecord.project?.worktree ?? null);
|
||||
let changed = false;
|
||||
const next: typeof incomingRecord = { ...incomingRecord };
|
||||
|
||||
if (!incomingDirectory && existingDirectory) {
|
||||
next.directory = existingRecord.directory;
|
||||
changed = true;
|
||||
}
|
||||
if (!incomingWorktree && existingWorktree) {
|
||||
next.project = {
|
||||
...(existingRecord.project ?? {}),
|
||||
...(incomingRecord.project ?? {}),
|
||||
worktree: existingRecord.project?.worktree,
|
||||
};
|
||||
changed = true;
|
||||
} else if (!incomingRecord.project && existingRecord.project) {
|
||||
next.project = existingRecord.project;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : incoming;
|
||||
};
|
||||
|
||||
const locationOf = (session: Session): SessionLocation | null => session.time?.archived ? null : ({
|
||||
directory: resolveGlobalSessionDirectory(session),
|
||||
parentId: parentIdOf(session),
|
||||
});
|
||||
|
||||
const sameLocation = (left: SessionLocation, right: SessionLocation): boolean => (
|
||||
left.directory === right.directory
|
||||
&& left.parentId === right.parentId
|
||||
);
|
||||
|
||||
const appendToBucket = (buckets: Map<string, string[]>, key: string, sessionId: string): void => {
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) bucket.push(sessionId);
|
||||
else buckets.set(key, [sessionId]);
|
||||
};
|
||||
|
||||
export const buildGlobalSessionStructure = (
|
||||
activeSessions: readonly Session[],
|
||||
): GlobalSessionStructure => {
|
||||
const activeRootIds: string[] = [];
|
||||
const activeChildrenByParentId = new Map<string, string[]>();
|
||||
const activeIdsByDirectory = new Map<string, string[]>();
|
||||
|
||||
const index = (
|
||||
session: Session,
|
||||
roots: string[],
|
||||
children: Map<string, string[]>,
|
||||
directories: Map<string, string[]>,
|
||||
): void => {
|
||||
const location = locationOf(session);
|
||||
if (!location) return;
|
||||
if (location.parentId) appendToBucket(children, location.parentId, session.id);
|
||||
else roots.push(session.id);
|
||||
if (location.directory) appendToBucket(directories, location.directory, session.id);
|
||||
};
|
||||
|
||||
for (const session of activeSessions) index(session, activeRootIds, activeChildrenByParentId, activeIdsByDirectory);
|
||||
|
||||
return {
|
||||
activeSessionIds: activeSessions.map((session) => session.id),
|
||||
activeRootIds,
|
||||
activeChildrenByParentId,
|
||||
activeIdsByDirectory,
|
||||
};
|
||||
};
|
||||
|
||||
const recordBucketChange = (
|
||||
changes: Map<string, BucketChange>,
|
||||
key: string,
|
||||
sessionId: string,
|
||||
operation: 'add' | 'remove',
|
||||
): void => {
|
||||
const change = changes.get(key) ?? { additions: new Set<string>(), removals: new Set<string>() };
|
||||
if (operation === 'add') {
|
||||
change.removals.delete(sessionId);
|
||||
change.additions.delete(sessionId);
|
||||
change.additions.add(sessionId);
|
||||
} else {
|
||||
change.additions.delete(sessionId);
|
||||
change.removals.add(sessionId);
|
||||
}
|
||||
changes.set(key, change);
|
||||
};
|
||||
|
||||
const applyListChange = (
|
||||
source: readonly string[],
|
||||
change: BucketChange,
|
||||
): readonly string[] => {
|
||||
if (change.additions.size === 0 && change.removals.size === 0) return source;
|
||||
const additions = [...change.additions].reverse();
|
||||
const added = new Set(additions);
|
||||
const retained = source.filter((id) => !change.removals.has(id) && !added.has(id));
|
||||
const next = [...additions, ...retained];
|
||||
if (next.length === source.length && next.every((id, index) => id === source[index])) return source;
|
||||
return next;
|
||||
};
|
||||
|
||||
const applyBucketChanges = (
|
||||
source: ReadonlyMap<string, readonly string[]>,
|
||||
changes: Map<string, BucketChange>,
|
||||
): ReadonlyMap<string, readonly string[]> => {
|
||||
if (changes.size === 0) return source;
|
||||
let next: Map<string, readonly string[]> | null = null;
|
||||
for (const [key, change] of changes) {
|
||||
const previous = source.get(key) ?? [];
|
||||
const bucket = applyListChange(previous, change);
|
||||
if (bucket === previous) continue;
|
||||
next ??= new Map(source);
|
||||
if (bucket.length === 0) next.delete(key);
|
||||
else next.set(key, bucket);
|
||||
}
|
||||
return next ?? source;
|
||||
};
|
||||
|
||||
export const applyGlobalSessionStructureMutations = (
|
||||
structure: GlobalSessionStructure,
|
||||
mutations: readonly GlobalSessionStructureMutation[],
|
||||
): GlobalSessionStructure => {
|
||||
const activeRoots: BucketChange = { additions: new Set(), removals: new Set() };
|
||||
const activeSessions: BucketChange = { additions: new Set(), removals: new Set() };
|
||||
const activeChildren = new Map<string, BucketChange>();
|
||||
const activeDirectories = new Map<string, BucketChange>();
|
||||
|
||||
const record = (location: SessionLocation, sessionId: string, operation: 'add' | 'remove'): void => {
|
||||
if (operation === 'add') {
|
||||
activeSessions.removals.delete(sessionId);
|
||||
activeSessions.additions.delete(sessionId);
|
||||
activeSessions.additions.add(sessionId);
|
||||
} else {
|
||||
activeSessions.additions.delete(sessionId);
|
||||
activeSessions.removals.add(sessionId);
|
||||
}
|
||||
if (location.parentId) recordBucketChange(activeChildren, location.parentId, sessionId, operation);
|
||||
else if (operation === 'add') {
|
||||
activeRoots.removals.delete(sessionId);
|
||||
activeRoots.additions.delete(sessionId);
|
||||
activeRoots.additions.add(sessionId);
|
||||
} else {
|
||||
activeRoots.additions.delete(sessionId);
|
||||
activeRoots.removals.add(sessionId);
|
||||
}
|
||||
if (location.directory) recordBucketChange(activeDirectories, location.directory, sessionId, operation);
|
||||
};
|
||||
|
||||
for (const mutation of mutations) {
|
||||
const previousLocation = mutation.previous ? locationOf(mutation.previous) : null;
|
||||
const nextLocation = mutation.next ? locationOf(mutation.next) : null;
|
||||
if (previousLocation && nextLocation && sameLocation(previousLocation, nextLocation)) continue;
|
||||
if (previousLocation) record(previousLocation, mutation.sessionId, 'remove');
|
||||
if (nextLocation) record(nextLocation, mutation.sessionId, 'add');
|
||||
}
|
||||
|
||||
const next: GlobalSessionStructure = {
|
||||
activeSessionIds: applyListChange(structure.activeSessionIds, activeSessions),
|
||||
activeRootIds: applyListChange(structure.activeRootIds, activeRoots),
|
||||
activeChildrenByParentId: applyBucketChanges(structure.activeChildrenByParentId, activeChildren),
|
||||
activeIdsByDirectory: applyBucketChanges(structure.activeIdsByDirectory, activeDirectories),
|
||||
};
|
||||
return next.activeSessionIds === structure.activeSessionIds
|
||||
&& next.activeRootIds === structure.activeRootIds
|
||||
&& next.activeChildrenByParentId === structure.activeChildrenByParentId
|
||||
&& next.activeIdsByDirectory === structure.activeIdsByDirectory
|
||||
? structure
|
||||
: next;
|
||||
};
|
||||
@@ -27,6 +27,13 @@ describe('useGlobalSessionsStore', () => {
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
sessionsByDirectory: new Map(),
|
||||
entityById: new Map(),
|
||||
structure: {
|
||||
activeSessionIds: [],
|
||||
activeRootIds: [],
|
||||
activeChildrenByParentId: new Map(),
|
||||
activeIdsByDirectory: new Map(),
|
||||
},
|
||||
hasLoaded: false,
|
||||
status: 'idle',
|
||||
});
|
||||
@@ -114,11 +121,13 @@ describe('useGlobalSessionsStore', () => {
|
||||
expect(useGlobalSessionsStore.getState().archivedSessions).toBe(archivedSessions);
|
||||
|
||||
const activeSessions = useGlobalSessionsStore.getState().activeSessions;
|
||||
const structure = useGlobalSessionsStore.getState().structure;
|
||||
useGlobalSessionsStore.getState().upsertSession({
|
||||
...archived,
|
||||
time: { created: 1, updated: 4, archived: 3 },
|
||||
});
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toBe(activeSessions);
|
||||
expect(useGlobalSessionsStore.getState().structure).toBe(structure);
|
||||
});
|
||||
|
||||
test('applies a batch of session upserts in one store publication', () => {
|
||||
@@ -136,6 +145,90 @@ describe('useGlobalSessionsStore', () => {
|
||||
expect(useGlobalSessionsStore.getState().activeSessions.map((session) => session.id)).toEqual(['ses_2', 'ses_1']);
|
||||
expect(publications).toBe(1);
|
||||
});
|
||||
|
||||
test('indexes a large batch of subagents in one store publication', () => {
|
||||
const parent = buildSession('https://share.example/parent', { id: 'ses_parent' });
|
||||
const children = Array.from({ length: 1_000 }, (_, index) => buildSession(
|
||||
`https://share.example/child-${index}`,
|
||||
{ id: `ses_child_${index}`, parentID: parent.id },
|
||||
));
|
||||
let publications = 0;
|
||||
const unsubscribe = useGlobalSessionsStore.subscribe(() => {
|
||||
publications += 1;
|
||||
});
|
||||
|
||||
useGlobalSessionsStore.getState().upsertSessions([parent, ...children]);
|
||||
|
||||
unsubscribe();
|
||||
const state = useGlobalSessionsStore.getState();
|
||||
expect(publications).toBe(1);
|
||||
expect(state.structure.activeRootIds).toEqual([parent.id]);
|
||||
expect(state.structure.activeChildrenByParentId.get(parent.id)?.length).toBe(1_000);
|
||||
});
|
||||
|
||||
test('preserves hierarchy references for entity-only updates', () => {
|
||||
const parent = buildSession('https://share.example/parent', { id: 'ses_parent', directory: '/repo' });
|
||||
const child = buildSession('https://share.example/child', {
|
||||
id: 'ses_child',
|
||||
directory: '/repo',
|
||||
parentID: parent.id,
|
||||
});
|
||||
useGlobalSessionsStore.getState().upsertSessions([parent, child]);
|
||||
const previous = useGlobalSessionsStore.getState();
|
||||
const previousChildren = previous.structure.activeChildrenByParentId.get(parent.id);
|
||||
|
||||
useGlobalSessionsStore.getState().upsertSession({
|
||||
...child,
|
||||
title: 'Renamed child',
|
||||
time: { ...child.time, updated: 3 },
|
||||
});
|
||||
|
||||
const next = useGlobalSessionsStore.getState();
|
||||
expect(next.structure).toBe(previous.structure);
|
||||
expect(next.structure.activeChildrenByParentId.get(parent.id)).toBe(previousChildren);
|
||||
expect(next.entityById.get(child.id)?.title).toBe('Renamed child');
|
||||
});
|
||||
|
||||
test('updates only affected hierarchy buckets when a session is reparented', () => {
|
||||
const parentA = buildSession('https://share.example/a', { id: 'ses_parent_a' });
|
||||
const parentB = buildSession('https://share.example/b', { id: 'ses_parent_b' });
|
||||
const parentC = buildSession('https://share.example/c', { id: 'ses_parent_c' });
|
||||
const child = buildSession('https://share.example/child', { id: 'ses_child', parentID: parentA.id });
|
||||
const unrelatedChild = buildSession('https://share.example/other', { id: 'ses_other', parentID: parentC.id });
|
||||
useGlobalSessionsStore.getState().upsertSessions([parentA, parentB, parentC, child, unrelatedChild]);
|
||||
const previous = useGlobalSessionsStore.getState().structure;
|
||||
const unrelatedBucket = previous.activeChildrenByParentId.get(parentC.id);
|
||||
|
||||
useGlobalSessionsStore.getState().upsertSession({ ...child, parentID: parentB.id });
|
||||
|
||||
const next = useGlobalSessionsStore.getState().structure;
|
||||
expect(next).not.toBe(previous);
|
||||
expect(next.activeChildrenByParentId.get(parentA.id)).toBe(undefined);
|
||||
expect([...next.activeChildrenByParentId.get(parentB.id) ?? []]).toEqual([child.id]);
|
||||
expect(next.activeChildrenByParentId.get(parentC.id)).toBe(unrelatedBucket);
|
||||
});
|
||||
|
||||
test('applies ordered mixed mutations in one publication', () => {
|
||||
const original = buildSession('https://share.example/original', { id: 'ses_original' });
|
||||
useGlobalSessionsStore.getState().upsertSession(original);
|
||||
let publications = 0;
|
||||
const unsubscribe = useGlobalSessionsStore.subscribe(() => {
|
||||
publications += 1;
|
||||
});
|
||||
|
||||
useGlobalSessionsStore.getState().applySessionMutations([
|
||||
{ type: 'upsert', session: buildSession('https://share.example/temporary', { id: 'ses_temporary' }) },
|
||||
{ type: 'remove', sessionId: original.id },
|
||||
{ type: 'remove', sessionId: 'ses_temporary' },
|
||||
{ type: 'upsert', session: buildSession('https://share.example/final', { id: 'ses_final' }) },
|
||||
]);
|
||||
|
||||
unsubscribe();
|
||||
const state = useGlobalSessionsStore.getState();
|
||||
expect(publications).toBe(1);
|
||||
expect(state.activeSessions.map((session) => session.id)).toEqual(['ses_final']);
|
||||
expect(state.structure.activeRootIds).toEqual(['ses_final']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeLiveSessionWithGlobalSession', () => {
|
||||
|
||||
@@ -9,6 +9,17 @@ import { raiseSessionOrderingBaselines } from '@/sync/session-ordering';
|
||||
import { mapWithConcurrency } from '@/lib/concurrency';
|
||||
import { persistManagedChatSessions, readManagedChatSessions } from '@/sync/persist-cache';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { countSyncPerformance } from '@/sync/performance-diagnostics';
|
||||
import {
|
||||
applyGlobalSessionStructureMutations,
|
||||
buildGlobalSessionStructure,
|
||||
mergeSessionDirectoryMetadata,
|
||||
resolveGlobalSessionDirectory,
|
||||
type GlobalSessionStructure,
|
||||
type GlobalSessionStructureMutation,
|
||||
} from './globalSessionStructure';
|
||||
|
||||
export { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory } from './globalSessionStructure';
|
||||
|
||||
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
@@ -17,9 +28,15 @@ type LoadResult = {
|
||||
archivedSessions: Session[];
|
||||
};
|
||||
|
||||
export type GlobalSessionMutation =
|
||||
| { type: 'upsert'; session: Session }
|
||||
| { type: 'remove'; sessionId: string };
|
||||
|
||||
type GlobalSessionsState = {
|
||||
activeSessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
entityById: ReadonlyMap<string, Session>;
|
||||
structure: GlobalSessionStructure;
|
||||
sessionsByDirectory: Map<string, Session[]>;
|
||||
reviewTransferBySessionId: Map<string, ReviewTransferDirection>;
|
||||
mutationRevision: number;
|
||||
@@ -29,6 +46,7 @@ type GlobalSessionsState = {
|
||||
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
|
||||
refreshSessionsForDirectories: (directories: Iterable<string>, fallbackActive?: Session[]) => Promise<LoadResult>;
|
||||
applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void;
|
||||
applySessionMutations: (mutations: readonly GlobalSessionMutation[]) => void;
|
||||
upsertSession: (session: Session) => void;
|
||||
upsertSessions: (sessions: Session[]) => void;
|
||||
removeSessions: (ids: Iterable<string>) => void;
|
||||
@@ -63,60 +81,6 @@ let inflightLoad: Promise<LoadResult> | null = null;
|
||||
// not apply its (stale) snapshot after the reset.
|
||||
let loadGeneration = 0;
|
||||
|
||||
export const resolveGlobalSessionDirectory = (session: Session): string | null => {
|
||||
const record = session as Session & {
|
||||
directory?: string | null;
|
||||
project?: { worktree?: string | null } | null;
|
||||
};
|
||||
|
||||
return normalizePath(record.directory ?? null)
|
||||
?? normalizePath(record.project?.worktree ?? null);
|
||||
};
|
||||
|
||||
export const mergeSessionDirectoryMetadata = (incoming: Session, existing?: Session | null): Session => {
|
||||
if (!existing) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
const incomingRecord = incoming as Session & {
|
||||
directory?: string | null;
|
||||
project?: ({ worktree?: string | null } & Record<string, unknown>) | null;
|
||||
};
|
||||
const existingRecord = existing as Session & {
|
||||
directory?: string | null;
|
||||
project?: ({ worktree?: string | null } & Record<string, unknown>) | null;
|
||||
};
|
||||
|
||||
const incomingDirectory = normalizePath(incomingRecord.directory ?? null);
|
||||
const incomingWorktree = normalizePath(incomingRecord.project?.worktree ?? null);
|
||||
const existingDirectory = normalizePath(existingRecord.directory ?? null);
|
||||
const existingWorktree = normalizePath(existingRecord.project?.worktree ?? null);
|
||||
|
||||
let changed = false;
|
||||
const next: typeof incomingRecord = { ...incomingRecord };
|
||||
|
||||
// Some live session updates omit stable raw directory metadata; keep the
|
||||
// cached value so project grouping does not temporarily lose the session.
|
||||
if (!incomingDirectory && existingDirectory) {
|
||||
next.directory = existingRecord.directory;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!incomingWorktree && existingWorktree) {
|
||||
next.project = {
|
||||
...(existingRecord.project ?? {}),
|
||||
...(incomingRecord.project ?? {}),
|
||||
worktree: existingRecord.project?.worktree,
|
||||
};
|
||||
changed = true;
|
||||
} else if (!incomingRecord.project && existingRecord.project) {
|
||||
next.project = existingRecord.project;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : incoming;
|
||||
};
|
||||
|
||||
export const mergeLiveSessionWithGlobalSession = (
|
||||
liveSession: Session,
|
||||
globalSession: Session,
|
||||
@@ -146,9 +110,12 @@ const buildSessionsByDirectory = (sessions: Session[]): Map<string, Session[]> =
|
||||
};
|
||||
|
||||
const getSessionSignature = (session: Session): string => {
|
||||
const record = session as Session & { parentID?: string | null; slug?: string | null };
|
||||
return [
|
||||
session.id,
|
||||
session.title ?? '',
|
||||
record.parentID ?? '',
|
||||
record.slug ?? '',
|
||||
session.time?.created ?? 0,
|
||||
session.time?.updated ?? 0,
|
||||
session.time?.archived ?? 0,
|
||||
@@ -158,7 +125,7 @@ const getSessionSignature = (session: Session): string => {
|
||||
].join(':');
|
||||
};
|
||||
|
||||
export const getSessionStructuralSignature = (session: Session): string => {
|
||||
const getSessionStructuralSignature = (session: Session): string => {
|
||||
const record = session as Session & { parentID?: string | null; slug?: string | null };
|
||||
return [
|
||||
session.id,
|
||||
@@ -311,14 +278,6 @@ const upsertSessionIntoList = (sessions: Session[], session: Session): Session[]
|
||||
return next;
|
||||
};
|
||||
|
||||
const removeSessionFromList = (sessions: Session[], sessionId: string): Session[] => {
|
||||
const index = sessions.findIndex((session) => session.id === sessionId);
|
||||
if (index === -1) {
|
||||
return sessions;
|
||||
}
|
||||
return [...sessions.slice(0, index), ...sessions.slice(index + 1)];
|
||||
};
|
||||
|
||||
const mergeSessionLists = (existing: Session[], incoming?: Session[]): Session[] => {
|
||||
if (!incoming || incoming.length === 0) {
|
||||
return existing;
|
||||
@@ -375,6 +334,14 @@ const applySnapshot = (
|
||||
const nextArchivedSessions = sameSessionList(state.archivedSessions, archivedSessions)
|
||||
? state.archivedSessions
|
||||
: archivedSessions;
|
||||
const sessionsChanged = nextActiveSessions !== state.activeSessions
|
||||
|| nextArchivedSessions !== state.archivedSessions;
|
||||
const nextEntityById = sessionsChanged
|
||||
? new Map([...nextActiveSessions, ...nextArchivedSessions].map((session) => [session.id, session]))
|
||||
: state.entityById;
|
||||
const nextStructure = nextActiveSessions !== state.activeSessions
|
||||
? buildGlobalSessionStructure(nextActiveSessions)
|
||||
: state.structure;
|
||||
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
|
||||
? state.sessionsByDirectory
|
||||
: buildSessionsByDirectory(nextActiveSessions);
|
||||
@@ -396,6 +363,8 @@ const applySnapshot = (
|
||||
return {
|
||||
activeSessions: nextActiveSessions,
|
||||
archivedSessions: nextArchivedSessions,
|
||||
entityById: nextEntityById,
|
||||
structure: nextStructure,
|
||||
sessionsByDirectory: nextSessionsByDirectory,
|
||||
reviewTransferBySessionId: nextReviewTransferMap,
|
||||
hasLoaded: true,
|
||||
@@ -435,42 +404,166 @@ const mutationRevisionPatch = (state: GlobalSessionsState, ids: Iterable<string>
|
||||
return { mutationRevision, mutationRevisionBySessionId };
|
||||
};
|
||||
|
||||
const applySessionUpserts = (state: GlobalSessionsState, sessions: Session[]): Partial<GlobalSessionsState> => {
|
||||
const materializeChangedSessionList = (
|
||||
previous: readonly Session[],
|
||||
memberIds: ReadonlySet<string>,
|
||||
additions: ReadonlySet<string>,
|
||||
entityById: ReadonlyMap<string, Session>,
|
||||
): Session[] => {
|
||||
const additionsInDisplayOrder = [...additions].reverse();
|
||||
const addedIds = new Set(additionsInDisplayOrder);
|
||||
const next = additionsInDisplayOrder.flatMap((sessionId) => {
|
||||
const session = entityById.get(sessionId);
|
||||
return session && memberIds.has(sessionId) ? [session] : [];
|
||||
});
|
||||
for (const previousSession of previous) {
|
||||
if (!memberIds.has(previousSession.id) || addedIds.has(previousSession.id)) continue;
|
||||
const session = entityById.get(previousSession.id);
|
||||
if (session) next.push(session);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const updateSessionsByDirectory = (
|
||||
previous: Map<string, Session[]>,
|
||||
previousStructure: GlobalSessionStructure,
|
||||
nextStructure: GlobalSessionStructure,
|
||||
entityById: ReadonlyMap<string, Session>,
|
||||
mutations: readonly GlobalSessionStructureMutation[],
|
||||
): Map<string, Session[]> => {
|
||||
const affectedDirectories = new Set<string>();
|
||||
const entityChangedDirectories = new Set<string>();
|
||||
for (const mutation of mutations) {
|
||||
const previousDirectory = mutation.previous && !mutation.previous.time?.archived
|
||||
? resolveGlobalSessionDirectory(mutation.previous)
|
||||
: null;
|
||||
const nextDirectory = mutation.next && !mutation.next.time?.archived
|
||||
? resolveGlobalSessionDirectory(mutation.next)
|
||||
: null;
|
||||
if (previousDirectory) affectedDirectories.add(previousDirectory);
|
||||
if (nextDirectory) {
|
||||
affectedDirectories.add(nextDirectory);
|
||||
entityChangedDirectories.add(nextDirectory);
|
||||
}
|
||||
}
|
||||
if (affectedDirectories.size === 0) return previous;
|
||||
|
||||
let next: Map<string, Session[]> | null = null;
|
||||
for (const directory of affectedDirectories) {
|
||||
const previousIds = previousStructure.activeIdsByDirectory.get(directory);
|
||||
const nextIds = nextStructure.activeIdsByDirectory.get(directory);
|
||||
if (previousIds === nextIds && !entityChangedDirectories.has(directory)) continue;
|
||||
next ??= new Map(previous);
|
||||
if (!nextIds || nextIds.length === 0) {
|
||||
next.delete(directory);
|
||||
continue;
|
||||
}
|
||||
next.set(directory, nextIds.flatMap((sessionId) => {
|
||||
const session = entityById.get(sessionId);
|
||||
return session ? [session] : [];
|
||||
}));
|
||||
}
|
||||
return next ?? previous;
|
||||
};
|
||||
|
||||
const applySessionMutations = (
|
||||
state: GlobalSessionsState,
|
||||
requestedMutations: readonly GlobalSessionMutation[],
|
||||
): Partial<GlobalSessionsState> => {
|
||||
let mutations = requestedMutations;
|
||||
if (isVSCodeRuntime()) {
|
||||
sessions = filterManagedChatsForRuntime(sessions, true);
|
||||
if (sessions.length === 0) return state;
|
||||
mutations = requestedMutations.filter((mutation) => (
|
||||
mutation.type === 'remove'
|
||||
|| filterManagedChatsForRuntime([mutation.session], true).length > 0
|
||||
));
|
||||
if (mutations.length === 0) return state;
|
||||
}
|
||||
const revisionPatch = mutationRevisionPatch(state, sessions.map((session) => session.id));
|
||||
let nextActiveSessions = state.activeSessions;
|
||||
let nextArchivedSessions = state.archivedSessions;
|
||||
const revisionPatch = mutationRevisionPatch(state, mutations.map((mutation) => (
|
||||
mutation.type === 'upsert' ? mutation.session.id : mutation.sessionId
|
||||
)));
|
||||
let nextEntityById: Map<string, Session> | null = null;
|
||||
const activeIds = new Set(state.activeSessions.map((session) => session.id));
|
||||
const archivedIds = new Set(state.archivedSessions.map((session) => session.id));
|
||||
const activeAdditions = new Set<string>();
|
||||
const archivedAdditions = new Set<string>();
|
||||
const structureMutations: GlobalSessionStructureMutation[] = [];
|
||||
let activeChanged = false;
|
||||
let archivedChanged = false;
|
||||
|
||||
for (const session of sessions) {
|
||||
const existingSession = nextActiveSessions.find((candidate) => candidate.id === session.id)
|
||||
?? nextArchivedSessions.find((candidate) => candidate.id === session.id)
|
||||
?? null;
|
||||
const sessionWithMetadata = mergeSessionDirectoryMetadata(session, existingSession);
|
||||
const addMember = (ids: Set<string>, additions: Set<string>, sessionId: string): void => {
|
||||
if (ids.has(sessionId)) return;
|
||||
ids.add(sessionId);
|
||||
additions.delete(sessionId);
|
||||
additions.add(sessionId);
|
||||
};
|
||||
const removeMember = (ids: Set<string>, additions: Set<string>, sessionId: string): void => {
|
||||
ids.delete(sessionId);
|
||||
additions.delete(sessionId);
|
||||
};
|
||||
|
||||
for (const mutation of mutations) {
|
||||
const sessionId = mutation.type === 'upsert' ? mutation.session.id : mutation.sessionId;
|
||||
const existingSession = (nextEntityById ?? state.entityById).get(sessionId) ?? null;
|
||||
if (mutation.type === 'remove') {
|
||||
if (!existingSession) continue;
|
||||
nextEntityById ??= new Map(state.entityById);
|
||||
nextEntityById.delete(sessionId);
|
||||
structureMutations.push({ sessionId, previous: existingSession, next: null });
|
||||
if (existingSession.time?.archived) {
|
||||
archivedChanged = true;
|
||||
removeMember(archivedIds, archivedAdditions, sessionId);
|
||||
} else {
|
||||
activeChanged = true;
|
||||
removeMember(activeIds, activeAdditions, sessionId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionWithMetadata = mergeSessionDirectoryMetadata(mutation.session, existingSession);
|
||||
if (existingSession && getSessionSignature(existingSession) === getSessionSignature(sessionWithMetadata)) continue;
|
||||
nextEntityById ??= new Map(state.entityById);
|
||||
nextEntityById.set(sessionId, sessionWithMetadata);
|
||||
structureMutations.push({ sessionId, previous: existingSession, next: sessionWithMetadata });
|
||||
const isArchived = Boolean(sessionWithMetadata.time?.archived);
|
||||
nextActiveSessions = isArchived
|
||||
? removeSessionFromList(nextActiveSessions, session.id)
|
||||
: upsertSessionIntoList(nextActiveSessions, sessionWithMetadata);
|
||||
nextArchivedSessions = isArchived
|
||||
? upsertSessionIntoList(nextArchivedSessions, sessionWithMetadata)
|
||||
: removeSessionFromList(nextArchivedSessions, session.id);
|
||||
const wasArchived = Boolean(existingSession?.time?.archived);
|
||||
if (existingSession) {
|
||||
if (wasArchived) archivedChanged = true;
|
||||
else activeChanged = true;
|
||||
}
|
||||
if (isArchived) {
|
||||
archivedChanged = true;
|
||||
removeMember(activeIds, activeAdditions, sessionId);
|
||||
addMember(archivedIds, archivedAdditions, sessionId);
|
||||
} else {
|
||||
activeChanged = true;
|
||||
removeMember(archivedIds, archivedAdditions, sessionId);
|
||||
addMember(activeIds, activeAdditions, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
nextActiveSessions === state.activeSessions
|
||||
&& nextArchivedSessions === state.archivedSessions
|
||||
) {
|
||||
if (!nextEntityById) {
|
||||
return revisionPatch;
|
||||
}
|
||||
const nextActiveSessions = activeChanged
|
||||
? materializeChangedSessionList(state.activeSessions, activeIds, activeAdditions, nextEntityById)
|
||||
: state.activeSessions;
|
||||
const nextArchivedSessions = archivedChanged
|
||||
? materializeChangedSessionList(state.archivedSessions, archivedIds, archivedAdditions, nextEntityById)
|
||||
: state.archivedSessions;
|
||||
const nextStructure = applyGlobalSessionStructureMutations(state.structure, structureMutations);
|
||||
|
||||
return {
|
||||
activeSessions: nextActiveSessions,
|
||||
archivedSessions: nextArchivedSessions,
|
||||
sessionsByDirectory: nextActiveSessions === state.activeSessions
|
||||
? state.sessionsByDirectory
|
||||
: buildSessionsByDirectory(nextActiveSessions),
|
||||
entityById: nextEntityById,
|
||||
structure: nextStructure,
|
||||
sessionsByDirectory: updateSessionsByDirectory(
|
||||
state.sessionsByDirectory,
|
||||
state.structure,
|
||||
nextStructure,
|
||||
nextEntityById,
|
||||
structureMutations,
|
||||
),
|
||||
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
|
||||
? state.reviewTransferBySessionId
|
||||
: buildReviewTransferMap(nextActiveSessions),
|
||||
@@ -494,10 +587,14 @@ const buildReviewTransferMap = (sessions: Session[]): Map<string, ReviewTransfer
|
||||
}
|
||||
|
||||
const initialManagedChatSessions = readManagedChatSessions();
|
||||
const initialEntityById = new Map(initialManagedChatSessions.map((session) => [session.id, session]));
|
||||
const initialStructure = buildGlobalSessionStructure(initialManagedChatSessions);
|
||||
|
||||
export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) => ({
|
||||
activeSessions: initialManagedChatSessions,
|
||||
archivedSessions: [],
|
||||
entityById: initialEntityById,
|
||||
structure: initialStructure,
|
||||
sessionsByDirectory: buildSessionsByDirectory(initialManagedChatSessions),
|
||||
reviewTransferBySessionId: buildReviewTransferMap(initialManagedChatSessions),
|
||||
mutationRevision: 0,
|
||||
@@ -513,13 +610,21 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
set((state) => applySnapshot(state, activeSessions, archivedSessions, status));
|
||||
},
|
||||
|
||||
applySessionMutations: (mutations) => {
|
||||
if (mutations.length === 0) return;
|
||||
set((state) => applySessionMutations(state, mutations));
|
||||
},
|
||||
|
||||
resetForRuntimeSwitch: () => {
|
||||
loadGeneration += 1;
|
||||
inflightLoad = null;
|
||||
const managedChatSessions = readManagedChatSessions();
|
||||
const entityById = new Map(managedChatSessions.map((session) => [session.id, session]));
|
||||
set({
|
||||
activeSessions: managedChatSessions,
|
||||
archivedSessions: [],
|
||||
entityById,
|
||||
structure: buildGlobalSessionStructure(managedChatSessions),
|
||||
sessionsByDirectory: buildSessionsByDirectory(managedChatSessions),
|
||||
reviewTransferBySessionId: buildReviewTransferMap(managedChatSessions),
|
||||
mutationRevision: 0,
|
||||
@@ -562,6 +667,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'ready');
|
||||
});
|
||||
const committed = get();
|
||||
raiseSessionOrderingBaselines(committed.activeSessions);
|
||||
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
|
||||
} catch (error) {
|
||||
if (generation !== loadGeneration) {
|
||||
@@ -614,6 +720,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
}
|
||||
|
||||
const { active, archived } = splitGlobalSessionsByArchived(fetched.sessions);
|
||||
const refreshedActiveIds = active.map((session) => session.id);
|
||||
|
||||
set((state) => {
|
||||
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active, fetched.directories);
|
||||
@@ -634,10 +741,12 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
|
||||
? state.sessionsByDirectory
|
||||
: buildSessionsByDirectory(nextActiveSessions);
|
||||
const activeChanged = nextActiveSessions !== state.activeSessions;
|
||||
const archivedChanged = nextArchivedSessions !== state.archivedSessions;
|
||||
|
||||
if (
|
||||
nextActiveSessions === state.activeSessions
|
||||
&& nextArchivedSessions === state.archivedSessions
|
||||
!activeChanged
|
||||
&& !archivedChanged
|
||||
&& nextSessionsByDirectory === state.sessionsByDirectory
|
||||
) {
|
||||
return state;
|
||||
@@ -646,6 +755,8 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
return {
|
||||
activeSessions: nextActiveSessions,
|
||||
archivedSessions: nextArchivedSessions,
|
||||
entityById: new Map([...nextActiveSessions, ...nextArchivedSessions].map((session) => [session.id, session])),
|
||||
structure: activeChanged ? buildGlobalSessionStructure(nextActiveSessions) : state.structure,
|
||||
sessionsByDirectory: nextSessionsByDirectory,
|
||||
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
|
||||
? state.reviewTransferBySessionId
|
||||
@@ -654,16 +765,23 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
});
|
||||
|
||||
const state = get();
|
||||
raiseSessionOrderingBaselines(refreshedActiveIds.flatMap((sessionId) => {
|
||||
const session = state.entityById.get(sessionId);
|
||||
return session && !session.time?.archived ? [session] : [];
|
||||
}));
|
||||
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
|
||||
},
|
||||
|
||||
upsertSession: (session) => {
|
||||
set((state) => applySessionUpserts(state, [session]));
|
||||
set((state) => applySessionMutations(state, [{ type: 'upsert', session }]));
|
||||
},
|
||||
|
||||
upsertSessions: (sessions) => {
|
||||
if (sessions.length === 0) return;
|
||||
set((state) => applySessionUpserts(state, sessions));
|
||||
set((state) => applySessionMutations(
|
||||
state,
|
||||
sessions.map((session) => ({ type: 'upsert' as const, session })),
|
||||
));
|
||||
},
|
||||
|
||||
removeSessions: (ids) => {
|
||||
@@ -672,26 +790,10 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const revisionPatch = mutationRevisionPatch(state, idSet);
|
||||
const nextActiveSessions = state.activeSessions.filter((session) => !idSet.has(session.id));
|
||||
const nextArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id));
|
||||
|
||||
if (
|
||||
nextActiveSessions.length === state.activeSessions.length
|
||||
&& nextArchivedSessions.length === state.archivedSessions.length
|
||||
) {
|
||||
return revisionPatch;
|
||||
}
|
||||
|
||||
return {
|
||||
activeSessions: nextActiveSessions,
|
||||
archivedSessions: nextArchivedSessions,
|
||||
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
|
||||
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
|
||||
...revisionPatch,
|
||||
};
|
||||
});
|
||||
set((state) => applySessionMutations(
|
||||
state,
|
||||
[...idSet].map((sessionId) => ({ type: 'remove' as const, sessionId })),
|
||||
));
|
||||
},
|
||||
|
||||
archiveSessions: (ids, archivedAt = Date.now()) => {
|
||||
@@ -701,13 +803,10 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const revisionPatch = mutationRevisionPatch(state, idSet);
|
||||
const movedSessions: Session[] = [];
|
||||
const nextActiveSessions = state.activeSessions.filter((session) => {
|
||||
if (!idSet.has(session.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const sessionId of idSet) {
|
||||
const session = state.entityById.get(sessionId);
|
||||
if (!session || session.time?.archived) continue;
|
||||
movedSessions.push({
|
||||
...session,
|
||||
time: {
|
||||
@@ -715,27 +814,25 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
archived: archivedAt,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
if (movedSessions.length === 0) {
|
||||
return revisionPatch;
|
||||
}
|
||||
|
||||
const remainingArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id));
|
||||
|
||||
if (movedSessions.length === 0) {
|
||||
return mutationRevisionPatch(state, idSet);
|
||||
}
|
||||
const patch = applySessionMutations(
|
||||
state,
|
||||
movedSessions.map((session) => ({ type: 'upsert' as const, session })),
|
||||
);
|
||||
return {
|
||||
activeSessions: nextActiveSessions,
|
||||
archivedSessions: [...movedSessions, ...remainingArchivedSessions],
|
||||
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
|
||||
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
|
||||
...revisionPatch,
|
||||
...patch,
|
||||
...mutationRevisionPatch(state, idSet),
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
useGlobalSessionsStore.subscribe((state, previous) => {
|
||||
countSyncPerformance('globalSessionPublications');
|
||||
if (
|
||||
state.activeSessions !== previous.activeSessions
|
||||
&& (state.status !== 'idle' || state.activeSessions.length > 0)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user