perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
This commit is contained in:
committed by
GitHub
parent
485efc7117
commit
85400459e9
@@ -18,6 +18,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPinnedStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||
import type { ChildSessionExport } from '@/lib/exportSession';
|
||||
@@ -25,7 +26,7 @@ import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSession
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import { nodeContainsSessionId } from './sessionNodeItemUtils';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
@@ -43,6 +44,8 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog
|
||||
import { FusionIcon } from '@/components/icons/FusionIcon';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
|
||||
@@ -57,7 +60,6 @@ type Props = {
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
archivedBucket?: boolean;
|
||||
currentSessionId: string | null;
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
hasSessionSearchQuery: boolean;
|
||||
@@ -70,9 +72,9 @@ type Props = {
|
||||
handleSaveEdit: (titleOverride?: string) => void;
|
||||
handleCancelEdit: () => void;
|
||||
toggleParent: (expansionKey: string) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
togglePinnedSession: (target: SessionPinnedTarget) => void;
|
||||
handleShareSession: (session: Session) => void;
|
||||
copiedSessionId: string | null;
|
||||
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
||||
@@ -101,16 +103,9 @@ type Props = {
|
||||
) => React.ReactNode;
|
||||
secondaryMeta?: SecondaryMeta | null;
|
||||
renderContext?: 'project' | 'recent';
|
||||
/**
|
||||
* Precomputed set of session IDs whose subtree contains the current
|
||||
* active session. Computed once per SessionGroupSection render (when
|
||||
* currentSessionId changes) instead of being recomputed in every row's
|
||||
* React.memo comparator.
|
||||
*/
|
||||
subtreeContainsActive: Set<string>;
|
||||
/**
|
||||
* Precomputed set of session IDs whose subtree contains the session
|
||||
* currently being edited. Same rationale as subtreeContainsActive.
|
||||
* currently being edited. Precomputed once per group render.
|
||||
*/
|
||||
subtreeContainsEditing: Set<string>;
|
||||
/**
|
||||
@@ -132,15 +127,52 @@ type Props = {
|
||||
* to fetch the right key for each child it produces.
|
||||
*/
|
||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
||||
/**
|
||||
* Batched index of live session objects keyed by id. The previous
|
||||
* implementation called `useSession(session.id)` per row, which used
|
||||
* `findLiveSession` to iterate every child-store on every SSE event.
|
||||
* With M visible rows that's M×child-stores per event; the batched
|
||||
* map turns it into a single Map.get per row. The parent falls back
|
||||
* to `useSession` only when this map returns undefined.
|
||||
*/
|
||||
liveSessionById: Map<string, Session>;
|
||||
};
|
||||
|
||||
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
||||
|
||||
const holdSessionRowPosition = (target: HTMLElement): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const row = target.closest<HTMLElement>('[data-session-row]');
|
||||
const container = row?.closest<HTMLElement>('.overlay-scrollbar-container');
|
||||
if (!row || !container) return;
|
||||
|
||||
cancelScrollAnchorByContainer.get(container)?.();
|
||||
|
||||
const initialTop = row.getBoundingClientRect().top;
|
||||
let remainingFrames = 3;
|
||||
let cancelled = false;
|
||||
let frameId: number | null = null;
|
||||
const cancel = () => {
|
||||
cancelled = true;
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
cancelScrollAnchorByContainer.delete(container);
|
||||
container.removeEventListener('wheel', cancel);
|
||||
container.removeEventListener('touchstart', cancel);
|
||||
};
|
||||
const restore = () => {
|
||||
if (cancelled || !row.isConnected || !container.isConnected) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
const delta = row.getBoundingClientRect().top - initialTop;
|
||||
if (Math.abs(delta) > 0.5) {
|
||||
container.scrollTop += delta;
|
||||
streamPerfCount('ui.sidebar.selection_scroll_anchor_adjustment');
|
||||
}
|
||||
remainingFrames -= 1;
|
||||
if (remainingFrames <= 0) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
frameId = window.requestAnimationFrame(restore);
|
||||
};
|
||||
|
||||
container.addEventListener('wheel', cancel, { passive: true });
|
||||
container.addEventListener('touchstart', cancel, { passive: true });
|
||||
cancelScrollAnchorByContainer.set(container, cancel);
|
||||
frameId = window.requestAnimationFrame(restore);
|
||||
};
|
||||
|
||||
type QuickSessionActionProps = {
|
||||
@@ -206,6 +238,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
||||
});
|
||||
|
||||
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_session_node.render');
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
node,
|
||||
@@ -213,7 +246,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
groupDirectory,
|
||||
projectId,
|
||||
archivedBucket = false,
|
||||
currentSessionId,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
hasSessionSearchQuery,
|
||||
@@ -248,11 +280,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
renderSessionNode,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
childRenderExtrasFor,
|
||||
liveSessionById,
|
||||
} = props;
|
||||
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
||||
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
||||
@@ -307,25 +337,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const session = node.session;
|
||||
// Batched live-session lookup. `liveSessionById` is built once per
|
||||
// Sidebar render from the same `useAllLiveSessions` selector that
|
||||
// `useSession` would have iterated per child-store, so a Map.get
|
||||
// here is equivalent in observed state but O(1) per row instead of
|
||||
// O(child-stores). Falls back to the row session when the live map
|
||||
// hasn't seen this id yet (sub-render latency between when a session
|
||||
// is created and when the SSE-driven aggregate picks it up).
|
||||
const resolvedSession = liveSessionById.get(session.id) ?? session;
|
||||
const resolvedSession = session;
|
||||
const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
||||
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
// Archived rows are historical and never need live state, yet they point at
|
||||
// dozens of (often deleted) worktrees — bootstrapping each from the sidebar
|
||||
// triggers a pointless session-list fetch + 6×2s empty-retry storm on startup.
|
||||
// Skip bootstrap for archived rows; the store ref is only read on-demand via
|
||||
// getState() in the export handlers (never subscribed). Active rows keep
|
||||
// bootstrapping so live cross-directory session/status still aggregates.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: !archivedBucket });
|
||||
// Directory bootstrap is scheduled once at sidebar level. A row only needs
|
||||
// the lightweight store reference for scoped state and export actions.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sync = useSync();
|
||||
|
||||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||
@@ -368,7 +388,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sessionGoal = getSessionGoal(resolvedSession);
|
||||
const sessionGoalGlyph = sessionGoal ? (
|
||||
<span
|
||||
@@ -379,10 +399,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="target" className="h-3 w-3" style={{ color: sessionGoalStatusColor[sessionGoal.status] }} />
|
||||
</span>
|
||||
) : null;
|
||||
const isActive = currentSessionId === session.id;
|
||||
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||||
const isPinnedSession = isSessionPinned(pinnedSessionIds, sessionDirectory, session.id);
|
||||
// Per-render-context expansion key: the same session can appear in both
|
||||
// the project's root and the "Recent" list, and expanding one should not
|
||||
// expand the other. Matches the format of menuInstanceKey.
|
||||
@@ -409,7 +428,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
let skipped = 0;
|
||||
for (const child of children) {
|
||||
try {
|
||||
await sync.ensureSessionRenderable(child.session.id);
|
||||
await sync.ensureSessionRenderable(child.session.id, false, sessionDirectory ?? undefined);
|
||||
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||||
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||||
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||||
@@ -426,7 +445,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
}
|
||||
return { children: results, skipped };
|
||||
}, [collectNodeDescendantIds, directoryStore, sync, t]);
|
||||
}, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]);
|
||||
|
||||
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
|
||||
if (count <= 0) return;
|
||||
@@ -441,7 +460,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
await sync.ensureSessionRenderable(session.id);
|
||||
await sync.ensureSessionRenderable(session.id, false, sessionDirectory);
|
||||
|
||||
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
||||
if (records.length === 0) {
|
||||
@@ -775,7 +794,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
|
||||
return;
|
||||
}
|
||||
handleSessionSelect(session.id, sessionDirectory, projectId);
|
||||
if (event?.currentTarget) holdSessionRowPosition(event.currentTarget);
|
||||
handleSessionSelect(session.id, sessionDirectory);
|
||||
};
|
||||
|
||||
// The selection/active highlight covers the WHOLE row box (gutter, edge
|
||||
@@ -832,7 +852,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="pencil-ai" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.rename')}
|
||||
</Item>
|
||||
<Item onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
|
||||
<Item onClick={() => sessionDirectory && togglePinnedSession({ directory: sessionDirectory, sessionId: session.id })} className="[&>svg]:mr-1">
|
||||
{isPinnedSession ? <Icon name="unpin" className="mr-1 h-4 w-4" /> : <Icon name="pushpin" className="mr-1 h-4 w-4" />}
|
||||
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
|
||||
</Item>
|
||||
@@ -1267,7 +1287,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
|
||||
? childRenderExtrasFor(child)
|
||||
: {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: '',
|
||||
@@ -1387,26 +1406,6 @@ const hasSetMembershipChangeInNode = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasResolvedSessionChangeInNode = (
|
||||
prevNode: SessionNode,
|
||||
nextNode: SessionNode,
|
||||
prevLiveSessionById: Map<string, Session>,
|
||||
nextLiveSessionById: Map<string, Session>,
|
||||
): boolean => {
|
||||
if (prevNode.session.id !== nextNode.session.id) return true;
|
||||
const sessionId = prevNode.session.id;
|
||||
if ((prevLiveSessionById.get(sessionId) ?? prevNode.session) !== (nextLiveSessionById.get(sessionId) ?? nextNode.session)) {
|
||||
return true;
|
||||
}
|
||||
if (prevNode.children.length !== nextNode.children.length) return true;
|
||||
for (let i = 0; i < prevNode.children.length; i += 1) {
|
||||
if (hasResolvedSessionChangeInNode(prevNode.children[i], nextNode.children[i], prevLiveSessionById, nextLiveSessionById)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
|
||||
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
|
||||
@@ -1428,6 +1427,7 @@ const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
|
||||
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (prev.node.session.id !== next.node.session.id) return false;
|
||||
if (prev.node.session !== next.node.session) return false;
|
||||
if (prev.depth !== next.depth) return false;
|
||||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||
if (prev.projectId !== next.projectId) return false;
|
||||
@@ -1442,13 +1442,15 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
|
||||
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
|
||||
|
||||
if (prev.liveSessionById !== next.liveSessionById
|
||||
&& hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.pinnedSessionIds !== next.pinnedSessionIds
|
||||
&& hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) {
|
||||
&& nodeHasPinnedMembershipChange(
|
||||
prev.node,
|
||||
next.node,
|
||||
prev.pinnedSessionIds,
|
||||
next.pinnedSessionIds,
|
||||
prev.groupDirectory,
|
||||
next.groupDirectory,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1456,14 +1458,6 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.currentSessionId !== next.currentSessionId
|
||||
&& (
|
||||
subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive)
|
||||
|| subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive)
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.editingId !== next.editingId
|
||||
&& (
|
||||
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|
||||
|
||||
Reference in New Issue
Block a user