perf: isolate chat streaming renders and reduce sidebar render cost (#1672)
Reworks the chat and session-sidebar render paths to cut render cascades, memory
churn, and UI jank on large sessions and big session trees. Behavior is preserved;
the changes are about *when* and *how much* the UI re-renders.
## Chat streaming
- Freeze the streaming message's parts in the bulk turn projection during streaming,
and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
no longer re-runs the whole-session projection or re-renders unrelated rows.
session with referential reuse of unchanged turns.
- Memoize message rows with field-aware comparators instead of reference equality.
- Replace the manual child-session polling in the task tool with the live SSE
stream + a one-shot load, removing a fetch/settle state machine.
## History loading & scroll
- Load an initial page fast, then prepend one older page in the background so the
scroll container has headroom and "load older on scroll-up" fires before the user
hits the absolute top.
- Compensate scroll synchronously (in a layout effect, before paint) for prepends —
including background prepends that don't originate from a user scroll — so the
viewport stays stable instead of judder-correcting on the next frame.
## Markdown rendering
- Render markdown synchronously *styled* on first paint (paragraphs, lists, code
cards, tables, inline code) instead of raw escaped text; the async pass then only
upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
- Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
chunk, avoiding a late stylesheet injection on first render.
## Sidebar
- Hoist per-row recursive tree walks out of row comparators into per-group
precomputed sets/keys; batch live-session lookups into a single map; add a
group-level memo boundary.
- Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.
## Sync layer
- Add a staleness guard so a slow message fetch can't repopulate a session the user
navigated away from.
- Throw on fetch failure for authoritative loaders so a transient blip can't read as
an empty server response.
## Cleanup
- Remove dead code (unused hooks, params, duplicated inline types) surfaced while
reworking the above.
## Known issue
- A rare, purely cosmetic first-paint width flash can still appear on large sessions;
it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
@@ -5,6 +5,11 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
// Archived buckets routinely grow into the hundreds/thousands; virtualize
|
||||
// when we cross this row count so the DOM stays bounded.
|
||||
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
|
||||
// Active/worktree groups can also grow large (a single worktree with 80+
|
||||
// sessions), and unlike the archive they're interactive from the start.
|
||||
// Virtualize eagerly for non-archived groups to keep the rendered row
|
||||
// count bounded. With overscan ~8 the visible behavior is identical.
|
||||
const ACTIVE_VIRTUALIZE_THRESHOLD = 30;
|
||||
// Compact rows in the archived bucket without nested subagents render
|
||||
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
|
||||
const ARCHIVED_ROW_ESTIMATE_PX = 28;
|
||||
@@ -18,6 +23,13 @@ import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDn
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
||||
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import {
|
||||
collectSubtreeContainingId,
|
||||
computeNodeStructureKey,
|
||||
nodeContainsSessionId,
|
||||
resolveMenuOpenSessionId,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
@@ -50,8 +62,16 @@ type Props = {
|
||||
deleteFolder: (scopeKey: string, folderId: string) => void;
|
||||
showDeletionDialog: boolean;
|
||||
setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>;
|
||||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null) => React.ReactNode;
|
||||
currentSessionDirectory: string | null;
|
||||
renderSessionNode: (
|
||||
node: SessionNode,
|
||||
depth?: number,
|
||||
groupDirectory?: string | null,
|
||||
projectId?: string | null,
|
||||
archivedBucket?: boolean,
|
||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
lastRepoStatus: boolean;
|
||||
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
|
||||
@@ -70,7 +90,13 @@ type Props = {
|
||||
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
currentSessionId: string | null;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
openSidebarMenuKey: string | null;
|
||||
liveSessionById: Map<string, Session>;
|
||||
prVisualStateByDirectoryBranch: Map<string, {
|
||||
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
number: number;
|
||||
@@ -97,9 +123,196 @@ type Props = {
|
||||
onToggleCollapsedGroup: (groupKey: string) => void;
|
||||
dragHandleProps?: SortableDragHandleProps | null;
|
||||
compactBodyPadding?: boolean;
|
||||
/**
|
||||
* Optional scroll container ref threaded from the outer ScrollableOverlay.
|
||||
* When provided, the virtualization effect can resolve the scrolling
|
||||
* ancestor synchronously and skip the getComputedStyle walk on every
|
||||
* render of an expanded archived bucket.
|
||||
*/
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
const groupContainsSessionId = (group: SessionGroup, sessionId: string | null): boolean => {
|
||||
if (!sessionId) return false;
|
||||
return group.sessions.some((node) => nodeContainsSessionId(node, sessionId));
|
||||
};
|
||||
|
||||
const groupHasPinnedMembershipChange = (
|
||||
group: SessionGroup,
|
||||
prevPinnedSessionIds: Set<string>,
|
||||
nextPinnedSessionIds: Set<string>,
|
||||
): boolean => {
|
||||
const visit = (node: SessionNode): boolean => {
|
||||
const sessionId = node.session.id;
|
||||
if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true;
|
||||
return node.children.some(visit);
|
||||
};
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const groupHasSessionOrderChange = (
|
||||
group: SessionGroup,
|
||||
prevSessionOrderIndex: Map<string, number>,
|
||||
nextSessionOrderIndex: Map<string, number>,
|
||||
): boolean => {
|
||||
const visit = (node: SessionNode): boolean => {
|
||||
const sessionId = node.session.id;
|
||||
if (prevSessionOrderIndex.get(sessionId) !== nextSessionOrderIndex.get(sessionId)) return true;
|
||||
return node.children.some(visit);
|
||||
};
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const groupHasExpansionMembershipChange = (
|
||||
group: SessionGroup,
|
||||
prevExpandedParents: Set<string>,
|
||||
nextExpandedParents: Set<string>,
|
||||
): boolean => {
|
||||
const bucketTag = group.isArchivedBucket ? 'archived' : 'active';
|
||||
const visit = (node: SessionNode): boolean => {
|
||||
const key = `project:${bucketTag}:${node.session.id}`;
|
||||
if (prevExpandedParents.has(key) !== nextExpandedParents.has(key)) return true;
|
||||
return node.children.some(visit);
|
||||
};
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const groupHasResolvedSessionChange = (
|
||||
group: SessionGroup,
|
||||
prevLiveSessionById: Map<string, Session>,
|
||||
nextLiveSessionById: Map<string, Session>,
|
||||
): boolean => {
|
||||
const visit = (node: SessionNode): boolean => {
|
||||
const sessionId = node.session.id;
|
||||
if ((prevLiveSessionById.get(sessionId) ?? node.session) !== (nextLiveSessionById.get(sessionId) ?? node.session)) {
|
||||
return true;
|
||||
}
|
||||
return node.children.some(visit);
|
||||
};
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
|
||||
if (!props.projectId) return undefined;
|
||||
return props.projectRepoStatus.has(props.projectId)
|
||||
? props.projectRepoStatus.get(props.projectId)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
// Bail on Object.is for the props that drive the most work: the group
|
||||
// itself, its key, and the group-level chrome. These change rarely and
|
||||
// any change should force a re-render of this group.
|
||||
if (prev.group !== next.group) return false;
|
||||
if (prev.groupKey !== next.groupKey) return false;
|
||||
if (prev.projectId !== next.projectId) return false;
|
||||
if (prev.hideGroupLabel !== next.hideGroupLabel) return false;
|
||||
if (prev.compactBodyPadding !== next.compactBodyPadding) return false;
|
||||
if (prev.groupSearchDataByGroup !== next.groupSearchDataByGroup) return false;
|
||||
if (prev.visibleSessionCount !== next.visibleSessionCount) return false;
|
||||
|
||||
if (prev.collapsedGroups !== next.collapsedGroups
|
||||
&& prev.collapsedGroups.has(prev.groupKey) !== next.collapsedGroups.has(next.groupKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.projectRepoStatus !== next.projectRepoStatus
|
||||
&& getProjectRepoStatusValue(prev) !== getProjectRepoStatusValue(next)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.pinnedSessionIds !== next.pinnedSessionIds
|
||||
&& groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.expandedParents !== next.expandedParents
|
||||
&& groupHasExpansionMembershipChange(next.group, prev.expandedParents, next.expandedParents)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.sessionOrderIndex !== next.sessionOrderIndex
|
||||
&& groupHasSessionOrderChange(next.group, prev.sessionOrderIndex, next.sessionOrderIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.currentSessionId !== next.currentSessionId
|
||||
&& (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.editingId !== next.editingId
|
||||
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.editTitle !== next.editTitle
|
||||
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
|
||||
const prevMenuSessionId = resolveMenuOpenSessionId(prev.group.sessions, prev.openSidebarMenuKey, 'project', Boolean(prev.group.isArchivedBucket));
|
||||
const nextMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, next.openSidebarMenuKey, 'project', Boolean(next.group.isArchivedBucket));
|
||||
if (prevMenuSessionId || nextMenuSessionId) return false;
|
||||
}
|
||||
|
||||
if (prev.liveSessionById !== next.liveSessionById
|
||||
&& groupHasResolvedSessionChange(next.group, prev.liveSessionById, next.liveSessionById)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Per-row / per-state props. The PR-visual-state map flips frequently
|
||||
// during bootstrap but a single group's value is usually stable, so we
|
||||
// compare only the value this group actually consumes instead of the
|
||||
// whole map reference.
|
||||
if (prev.prVisualStateByDirectoryBranch !== next.prVisualStateByDirectoryBranch) {
|
||||
const prevVal = prev.group?.directory && prev.group?.branch
|
||||
? prev.prVisualStateByDirectoryBranch.get(`${prev.group.directory}::${prev.group.branch.trim()}`)
|
||||
: undefined;
|
||||
const nextVal = next.group?.directory && next.group?.branch
|
||||
? next.prVisualStateByDirectoryBranch.get(`${next.group.directory}::${next.group.branch.trim()}`)
|
||||
: undefined;
|
||||
if (!Object.is(prevVal, nextVal)) return false;
|
||||
}
|
||||
|
||||
// Other props are typically stable references from the parent. Default
|
||||
// to reference equality (the cheap path) and only re-render when the
|
||||
// parent actually swapped something.
|
||||
return (
|
||||
prev.hasSessionSearchQuery === next.hasSessionSearchQuery
|
||||
&& prev.normalizedSessionSearchQuery === next.normalizedSessionSearchQuery
|
||||
&& prev.hideDirectoryControls === next.hideDirectoryControls
|
||||
&& prev.collapsedFolderIds === next.collapsedFolderIds
|
||||
&& prev.toggleFolderCollapse === next.toggleFolderCollapse
|
||||
&& prev.renameFolder === next.renameFolder
|
||||
&& prev.deleteFolder === next.deleteFolder
|
||||
&& prev.showDeletionDialog === next.showDeletionDialog
|
||||
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
|
||||
&& prev.renderSessionNode === next.renderSessionNode
|
||||
&& prev.lastRepoStatus === next.lastRepoStatus
|
||||
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
|
||||
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
|
||||
&& prev.mobileVariant === next.mobileVariant
|
||||
&& prev.alwaysShowActions === next.alwaysShowActions
|
||||
&& prev.activeProjectId === next.activeProjectId
|
||||
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
|
||||
&& prev.setActiveMainTab === next.setActiveMainTab
|
||||
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
|
||||
&& prev.openNewSessionDraft === next.openNewSessionDraft
|
||||
&& prev.addSessionToFolder === next.addSessionToFolder
|
||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
||||
&& prev.renamingFolderId === next.renamingFolderId
|
||||
&& prev.renameFolderDraft === next.renameFolderDraft
|
||||
&& prev.setRenameFolderDraft === next.setRenameFolderDraft
|
||||
&& prev.setRenamingFolderId === next.setRenamingFolderId
|
||||
&& prev.onToggleCollapsedGroup === next.onToggleCollapsedGroup
|
||||
&& prev.dragHandleProps === next.dragHandleProps
|
||||
&& prev.scrollContainerRef === next.scrollContainerRef
|
||||
);
|
||||
};
|
||||
|
||||
function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
group,
|
||||
@@ -138,10 +351,14 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
setRenamingFolderId,
|
||||
pinnedSessionIds,
|
||||
sessionOrderIndex,
|
||||
currentSessionId,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
prVisualStateByDirectoryBranch,
|
||||
onToggleCollapsedGroup,
|
||||
dragHandleProps,
|
||||
compactBodyPadding = false,
|
||||
scrollContainerRef,
|
||||
} = props;
|
||||
|
||||
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
|
||||
@@ -254,6 +471,79 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
|
||||
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
|
||||
|
||||
// Precompute per-row "subtree contains active session" and "subtree contains
|
||||
// editing session" lookups once per render. The previous design walked the
|
||||
// node tree inside SessionNodeItem.areEqual for every row, which is O(M^2)
|
||||
// across the whole sidebar. These sets let areEqual answer with a single
|
||||
// Set.has lookup, so the cost is O(M) once per SessionGroupSection render.
|
||||
const renderContextForGroup = 'project' as const;
|
||||
const subtreeContainsActive = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
collectSubtreeContainingId(sourceGroupNodes, currentSessionId, set);
|
||||
allFoldersForGroup.forEach(({ nodes }) => {
|
||||
collectSubtreeContainingId(nodes, currentSessionId, set);
|
||||
});
|
||||
return set;
|
||||
}, [sourceGroupNodes, allFoldersForGroup, currentSessionId]);
|
||||
|
||||
const subtreeContainsEditing = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
|
||||
allFoldersForGroup.forEach(({ nodes }) => {
|
||||
collectSubtreeContainingId(nodes, editingId, set);
|
||||
});
|
||||
return set;
|
||||
}, [sourceGroupNodes, allFoldersForGroup, editingId]);
|
||||
|
||||
const menuOpenSessionId = React.useMemo(() => {
|
||||
if (!openSidebarMenuKey) return null;
|
||||
const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
||||
if (fromSource) return fromSource;
|
||||
for (const { nodes } of allFoldersForGroup) {
|
||||
const id = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
||||
if (id) return id;
|
||||
}
|
||||
return null;
|
||||
}, [openSidebarMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]);
|
||||
|
||||
const buildNodeStructureKeyByNode = React.useCallback((nodes: SessionNode[]): WeakMap<SessionNode, string> => {
|
||||
const map = new WeakMap<SessionNode, string>();
|
||||
const visit = (node: SessionNode): void => {
|
||||
map.set(node, computeNodeStructureKey(node));
|
||||
for (const child of node.children) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
nodes.forEach(visit);
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
const nodeStructureKeyBySourceNode = React.useMemo(
|
||||
() => buildNodeStructureKeyByNode(sourceGroupNodes),
|
||||
[buildNodeStructureKeyByNode, sourceGroupNodes],
|
||||
);
|
||||
const nodeStructureKeyByFolderNode = React.useMemo(
|
||||
() => {
|
||||
const map = new WeakMap<SessionNode, string>();
|
||||
allFoldersForGroup.forEach(({ nodes }) => {
|
||||
nodes.forEach((node) => map.set(node, computeNodeStructureKey(node)));
|
||||
});
|
||||
return map;
|
||||
},
|
||||
[allFoldersForGroup],
|
||||
);
|
||||
|
||||
const resolveNodeStructureKey = React.useCallback((node: SessionNode): string => {
|
||||
return nodeStructureKeyBySourceNode.get(node) ?? nodeStructureKeyByFolderNode.get(node) ?? '';
|
||||
}, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]);
|
||||
|
||||
const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(child),
|
||||
}), [subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
|
||||
|
||||
const totalSessions = ungroupedSessions.length;
|
||||
const visibleSessions = group.isArchivedBucket
|
||||
? ungroupedSessions
|
||||
@@ -263,15 +553,21 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
const remainingCount = totalSessions - visibleSessions.length;
|
||||
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
|
||||
|
||||
// Virtualize the archived bucket once it grows past a threshold. The
|
||||
// archived list is the only group that can routinely hit hundreds or
|
||||
// thousands of rows (projects accumulate archived sessions over time);
|
||||
// every other group renders eagerly because they're small. All hooks
|
||||
// below MUST stay above the search-empty early-return so they fire in
|
||||
// the same order every render — rules-of-hooks.
|
||||
// Virtualize large groups. Archived buckets grow into the hundreds or
|
||||
// thousands of rows; active/worktree groups can also hit 80+ sessions
|
||||
// when a single worktree accumulates over time. Both paths share the
|
||||
// same virtua Virtualizer; the threshold just controls when we mount
|
||||
// it. The visible behavior is identical because virtua uses overscan
|
||||
// (8) for the buffer zone. All hooks below MUST stay above the
|
||||
// search-empty early-return so they fire in the same order every
|
||||
// render — rules-of-hooks.
|
||||
const shouldVirtualizeArchived = group.isArchivedBucket === true
|
||||
&& !hasSessionSearchQuery
|
||||
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
|
||||
const shouldVirtualizeActive = group.isArchivedBucket !== true
|
||||
&& !hasSessionSearchQuery
|
||||
&& visibleSessions.length >= ACTIVE_VIRTUALIZE_THRESHOLD;
|
||||
const shouldVirtualize = shouldVirtualizeArchived || shouldVirtualizeActive;
|
||||
|
||||
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const archivedScrollRef = React.useRef<HTMLElement | null>(null);
|
||||
@@ -284,22 +580,31 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
// element and renders rows in the wrong subset / position.
|
||||
const [archivedScrollMargin, setArchivedScrollMargin] = React.useState(0);
|
||||
|
||||
// Find the nearest scrolling ancestor by walking up the DOM. The sidebar
|
||||
// routes its scroll through `ScrollableOverlay` higher up the tree;
|
||||
// threading a ref through every intermediate component would be invasive
|
||||
// for this single use case.
|
||||
// Resolve the scrolling ancestor and measure the virtual container's offset
|
||||
// from its content origin, both on every render. The container ref is null
|
||||
// while the archived bucket is collapsed (the body isn't mounted), so a
|
||||
// Resolve the scrolling ancestor. When the parent has threaded a
|
||||
// `scrollContainerRef` (Layer 1.4), use it directly to skip the
|
||||
// `getComputedStyle` walk on every render of an expanded archived
|
||||
// bucket — the walk is one of the more expensive operations in the
|
||||
// hot path because it forces a style recalc on every parent up the
|
||||
// tree. Fall back to the legacy walk only when the ref is missing.
|
||||
//
|
||||
// We also still re-run when the archive flips between expanded/collapsed,
|
||||
// and on a ResizeObserver-driven layout change of the container, so a
|
||||
// dep-gated effect that only fires when shouldVirtualizeArchived flips
|
||||
// would miss the eventual mount and leave the scroll element null forever.
|
||||
// Running on every render lets us pick up the container as soon as
|
||||
// expanding the bucket mounts it; the cached scroll element is reused as
|
||||
// long as it still contains the container. Both state setters compare
|
||||
// before writing, so a stable layout produces no state churn.
|
||||
// would miss the eventual mount and leave the scroll element null.
|
||||
const [, setLayoutVersion] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (!shouldVirtualize) return;
|
||||
const container = archivedVirtualContainerRef.current;
|
||||
if (!container) return;
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, [shouldVirtualize]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
React.useLayoutEffect(() => {
|
||||
if (!shouldVirtualizeArchived) {
|
||||
if (!shouldVirtualize) {
|
||||
if (archivedScrollEl !== null) setArchivedScrollEl(null);
|
||||
archivedScrollRef.current = null;
|
||||
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
|
||||
@@ -312,7 +617,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
let scrollEl: HTMLElement | null = archivedScrollEl;
|
||||
if (!scrollEl || !scrollEl.contains(container)) {
|
||||
const providedScrollEl = scrollContainerRef?.current ?? null;
|
||||
if (providedScrollEl && providedScrollEl.contains(container)) {
|
||||
scrollEl = providedScrollEl;
|
||||
if (scrollEl !== archivedScrollEl) {
|
||||
archivedScrollRef.current = scrollEl;
|
||||
setArchivedScrollEl(scrollEl);
|
||||
return;
|
||||
}
|
||||
} else if (!scrollEl || !scrollEl.contains(container)) {
|
||||
// Walk up to find the nearest scrolling ancestor. Only happens on
|
||||
// first mount or if the DOM tree restructured.
|
||||
let el: HTMLElement | null = container.parentElement;
|
||||
@@ -327,8 +640,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
if (scrollEl !== archivedScrollEl) {
|
||||
archivedScrollRef.current = scrollEl;
|
||||
setArchivedScrollEl(scrollEl);
|
||||
// setState triggers a re-render; bail out and let the next pass
|
||||
// measure the margin against the fresh element.
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -339,11 +650,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
|
||||
});
|
||||
|
||||
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const collectGroupSessions = (nodes: SessionNode[]): Session[] => {
|
||||
// Hooks below MUST stay above the search-empty early-return so they
|
||||
// fire in the same order every render — rules-of-hooks.
|
||||
const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => {
|
||||
const collected: Session[] = [];
|
||||
const visit = (list: SessionNode[]) => {
|
||||
list.forEach((node) => {
|
||||
@@ -353,9 +662,52 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
};
|
||||
visit(nodes);
|
||||
return collected;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The "delete all in group" handler closes over the full list of
|
||||
// sessions in this group. Memoize so the recursive flatten only runs
|
||||
// when the underlying source group nodes change, not on every render.
|
||||
const allGroupSessions = React.useMemo(
|
||||
() => (group.isArchivedBucket ? collectGroupSessions(sourceGroupNodes) : []),
|
||||
[collectGroupSessions, sourceGroupNodes, group.isArchivedBucket],
|
||||
);
|
||||
|
||||
// Precompute the per-folder "delete all sessions in folder" list once
|
||||
// per render. The previous design ran a recursive `collectFolderSessions`
|
||||
// walk inside each folder's render, which is O(F × (S + F)) per group
|
||||
// render. With F=50 folders and S=200 archived sessions this is
|
||||
// significant; the precompute makes it O(F + S) once.
|
||||
const folderSessionsForDeleteById = React.useMemo(() => {
|
||||
if (!group.isArchivedBucket) return new Map<string, Session[]>();
|
||||
const result = new Map<string, Session[]>();
|
||||
const childIdsByParentId = new Map<string, string[]>();
|
||||
for (const { folder } of allFoldersForGroup) {
|
||||
if (!folder.parentId) continue;
|
||||
const existing = childIdsByParentId.get(folder.parentId) ?? [];
|
||||
existing.push(folder.id);
|
||||
childIdsByParentId.set(folder.parentId, existing);
|
||||
}
|
||||
const visit = (targetFolderId: string, seen: Set<string>): Session[] => {
|
||||
if (seen.has(targetFolderId)) return [];
|
||||
seen.add(targetFolderId);
|
||||
const directEntry = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId);
|
||||
const collected: Session[] = directEntry ? collectGroupSessions(directEntry.nodes) : [];
|
||||
const childIds = childIdsByParentId.get(targetFolderId) ?? [];
|
||||
for (const childId of childIds) {
|
||||
collected.push(...visit(childId, seen));
|
||||
}
|
||||
return collected;
|
||||
};
|
||||
for (const { folder } of allFoldersForGroup) {
|
||||
result.set(folder.id, visit(folder.id, new Set()));
|
||||
}
|
||||
return result;
|
||||
}, [allFoldersForGroup, collectGroupSessions, group.isArchivedBucket]);
|
||||
|
||||
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
|
||||
const isGitProject = projectId && projectRepoStatus.has(projectId)
|
||||
? Boolean(projectRepoStatus.get(projectId))
|
||||
: lastRepoStatus;
|
||||
@@ -437,15 +789,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
const subFolderItems = directSubFolders.length > 0
|
||||
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
|
||||
: undefined;
|
||||
const collectFolderSessions = (targetFolderId: string): Session[] => {
|
||||
const directNodes = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId)?.nodes ?? [];
|
||||
const childFolders = allFoldersForGroup.filter(({ folder: candidate }) => candidate.parentId === targetFolderId);
|
||||
return [
|
||||
...collectGroupSessions(directNodes),
|
||||
...childFolders.flatMap(({ folder: child }) => collectFolderSessions(child.id)),
|
||||
];
|
||||
};
|
||||
const folderSessionsForDelete = group.isArchivedBucket ? collectFolderSessions(folder.id) : [];
|
||||
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
||||
|
||||
return (
|
||||
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
|
||||
@@ -485,6 +829,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
});
|
||||
}}
|
||||
renderSessionNode={renderSessionNode}
|
||||
getRenderExtras={resolveNodeStructureKey
|
||||
? (node) => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
childRenderExtrasFor,
|
||||
})
|
||||
: undefined}
|
||||
groupDirectory={group.directory}
|
||||
projectId={projectId}
|
||||
mobileVariant={mobileVariant}
|
||||
@@ -546,7 +899,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
}}
|
||||
>
|
||||
{renderFolderItems()}
|
||||
{shouldVirtualizeArchived ? (
|
||||
{shouldVirtualize ? (
|
||||
<div ref={archivedVirtualContainerRef}>
|
||||
<Virtualizer
|
||||
data={visibleSessions}
|
||||
@@ -555,11 +908,23 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
scrollRef={archivedScrollRef}
|
||||
startMargin={archivedScrollMargin}
|
||||
>
|
||||
{(node) => renderSessionNode(node, 0, group.directory, projectId, true) as React.ReactElement}
|
||||
{(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
childRenderExtrasFor,
|
||||
}) as React.ReactElement}
|
||||
</Virtualizer>
|
||||
</div>
|
||||
) : (
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
childRenderExtrasFor,
|
||||
}))
|
||||
)}
|
||||
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||
@@ -846,3 +1211,5 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SessionGroupSection = React.memo(SessionGroupSectionBase, areGroupPropsEqual);
|
||||
|
||||
Reference in New Issue
Block a user