perf(ui): narrow session switch fanout

Keep sidebar rows independent from the active sync directory by moving export history loading behind an explicit-directory command.

Make active-project selection preserve project topology and remove activeProjectId from mounted group render props. Reuse a per-session-array ID index so subscription snapshots do not repeatedly scan session lists.
This commit is contained in:
c_w_xiaohei
2026-08-26 00:44:14 +08:00
parent 701173e399
commit fa1503a038
9 changed files with 74 additions and 39 deletions
@@ -286,7 +286,6 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
hideDirectoryControls: view.hideDirectoryControls,
mobileVariant: view.mobileVariant,
alwaysShowActions,
activeProjectId: view.activeProjectId,
notifyOnSubtasks,
pinnedSessionIds: collection.pinnedSessionIds,
sessionOrderIndex,
@@ -330,7 +329,6 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
setCopiedSessionId,
rowActions,
toggleParent,
view.activeProjectId,
view.hideDirectoryControls,
view.hasSessionSearchQuery,
view.mobileVariant,
@@ -113,7 +113,6 @@ const createProps = (): SessionGroupSectionProps => ({
resetGroupSessionLimit: () => undefined,
mobileVariant: false,
alwaysShowActions: false,
activeProjectId: 'project',
setActiveProjectIdOnly: () => undefined,
setActiveMainTab: () => undefined,
setSessionSwitcherOpen: () => undefined,
@@ -232,7 +232,6 @@ const areGroupPropsEqual = (prev: SessionGroupSectionProps, next: SessionGroupSe
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
&& prev.mobileVariant === next.mobileVariant
&& prev.alwaysShowActions === next.alwaysShowActions
&& prev.activeProjectId === next.activeProjectId
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
&& prev.openNewSessionDraft === next.openNewSessionDraft
@@ -58,7 +58,6 @@ type SessionProjectScrollerGroupProps = Pick<SessionGroupSectionProps,
| 'startFolderRename'
| 'setCopiedSessionId'
> & {
activeProjectId: string | null;
pinnedSessionIds: Set<string>;
sessionOrderIndex: Map<string, number>;
};
@@ -22,8 +22,8 @@ import { isSessionPinned, useSessionPinnedStore } from '@/stores/useSessionPinne
import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
@@ -383,10 +383,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
// project (falling back to the directory when no project is known) — a
// selection must survive mixing sessions from different worktrees.
const selectionScopeKey = projectId ?? sessionDirectory ?? null;
// 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 loadExportRecords = useSessionMessageRecordsForExport();
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const isRowSelected = useSessionMultiSelectStore(
@@ -478,8 +475,8 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
for (const child of children) {
try {
if (!sessionDirectory) throw new Error('Session directory is required for export');
await sync.loadCompleteHistory(child.session.id, sessionDirectory);
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
const childRecords = await loadExportRecords({ directory: sessionDirectory, sessionID: child.session.id });
if (!childRecords) throw new Error('Session runtime changed during export');
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
// SAFETY: OpenCode session payloads may carry the optional agent label used by exports.
const childAgent = (child.session as Session & { agent?: string }).agent;
@@ -496,7 +493,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
}
}
return { children: results, skipped };
}, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]);
}, [collectNodeDescendantIds, loadExportRecords, sessionDirectory, t]);
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
if (count <= 0) return;
@@ -511,14 +508,11 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
return;
}
try {
await sync.loadCompleteHistory(session.id, sessionDirectory);
} catch {
const records = await loadExportRecords({ directory: sessionDirectory, sessionID: session.id }).catch(() => null);
if (!records) {
toast.error(t('sessions.sidebar.session.export.failedLoadHistory'));
return;
}
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
if (records.length === 0) {
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
@@ -556,7 +550,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
downloadAsMarkdown(markdown, filename);
toast.success(t('sessions.sidebar.session.export.success'));
showSkippedSubtasksWarning(skippedSubtaskCount);
}, [collectChildExports, directoryStore, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, sync, t]);
}, [collectChildExports, loadExportRecords, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, t]);
const handleExportSession = React.useCallback(async () => {
if (node.children.length > 0) {
setExportIncludeSubtasks(true);