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, hideDirectoryControls: view.hideDirectoryControls,
mobileVariant: view.mobileVariant, mobileVariant: view.mobileVariant,
alwaysShowActions, alwaysShowActions,
activeProjectId: view.activeProjectId,
notifyOnSubtasks, notifyOnSubtasks,
pinnedSessionIds: collection.pinnedSessionIds, pinnedSessionIds: collection.pinnedSessionIds,
sessionOrderIndex, sessionOrderIndex,
@@ -330,7 +329,6 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
setCopiedSessionId, setCopiedSessionId,
rowActions, rowActions,
toggleParent, toggleParent,
view.activeProjectId,
view.hideDirectoryControls, view.hideDirectoryControls,
view.hasSessionSearchQuery, view.hasSessionSearchQuery,
view.mobileVariant, view.mobileVariant,
@@ -113,7 +113,6 @@ const createProps = (): SessionGroupSectionProps => ({
resetGroupSessionLimit: () => undefined, resetGroupSessionLimit: () => undefined,
mobileVariant: false, mobileVariant: false,
alwaysShowActions: false, alwaysShowActions: false,
activeProjectId: 'project',
setActiveProjectIdOnly: () => undefined, setActiveProjectIdOnly: () => undefined,
setActiveMainTab: () => undefined, setActiveMainTab: () => undefined,
setSessionSwitcherOpen: () => undefined, setSessionSwitcherOpen: () => undefined,
@@ -232,7 +232,6 @@ const areGroupPropsEqual = (prev: SessionGroupSectionProps, next: SessionGroupSe
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit && prev.resetGroupSessionLimit === next.resetGroupSessionLimit
&& prev.mobileVariant === next.mobileVariant && prev.mobileVariant === next.mobileVariant
&& prev.alwaysShowActions === next.alwaysShowActions && prev.alwaysShowActions === next.alwaysShowActions
&& prev.activeProjectId === next.activeProjectId
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly && prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen && prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
&& prev.openNewSessionDraft === next.openNewSessionDraft && prev.openNewSessionDraft === next.openNewSessionDraft
@@ -58,7 +58,6 @@ type SessionProjectScrollerGroupProps = Pick<SessionGroupSectionProps,
| 'startFolderRename' | 'startFolderRename'
| 'setCopiedSessionId' | 'setCopiedSessionId'
> & { > & {
activeProjectId: string | null;
pinnedSessionIds: Set<string>; pinnedSessionIds: Set<string>;
sessionOrderIndex: Map<string, number>; sessionOrderIndex: Map<string, number>;
}; };
@@ -22,8 +22,8 @@ import { isSessionPinned, useSessionPinnedStore } from '@/stores/useSessionPinne
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession'; import type { ChildSessionExport } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context'; import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync'; import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from '../folders/sessionFolderDnd'; import { DraggableSessionRow } from '../folders/sessionFolderDnd';
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; 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 // project (falling back to the directory when no project is known) — a
// selection must survive mixing sessions from different worktrees. // selection must survive mixing sessions from different worktrees.
const selectionScopeKey = projectId ?? sessionDirectory ?? null; const selectionScopeKey = projectId ?? sessionDirectory ?? null;
// Directory bootstrap is scheduled once at sidebar level. A row only needs const loadExportRecords = useSessionMessageRecordsForExport();
// 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); const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const isRowSelected = useSessionMultiSelectStore( const isRowSelected = useSessionMultiSelectStore(
@@ -478,8 +475,8 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
for (const child of children) { for (const child of children) {
try { try {
if (!sessionDirectory) throw new Error('Session directory is required for export'); if (!sessionDirectory) throw new Error('Session directory is required for export');
await sync.loadCompleteHistory(child.session.id, sessionDirectory); const childRecords = await loadExportRecords({ directory: sessionDirectory, sessionID: child.session.id });
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list; if (!childRecords) throw new Error('Session runtime changed during export');
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent'); const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
// SAFETY: OpenCode session payloads may carry the optional agent label used by exports. // SAFETY: OpenCode session payloads may carry the optional agent label used by exports.
const childAgent = (child.session as Session & { agent?: string }).agent; const childAgent = (child.session as Session & { agent?: string }).agent;
@@ -496,7 +493,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
} }
} }
return { children: results, skipped }; return { children: results, skipped };
}, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]); }, [collectNodeDescendantIds, loadExportRecords, sessionDirectory, t]);
const showSkippedSubtasksWarning = React.useCallback((count: number) => { const showSkippedSubtasksWarning = React.useCallback((count: number) => {
if (count <= 0) return; if (count <= 0) return;
@@ -511,14 +508,11 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
return; return;
} }
try { const records = await loadExportRecords({ directory: sessionDirectory, sessionID: session.id }).catch(() => null);
await sync.loadCompleteHistory(session.id, sessionDirectory); if (!records) {
} catch {
toast.error(t('sessions.sidebar.session.export.failedLoadHistory')); toast.error(t('sessions.sidebar.session.export.failedLoadHistory'));
return; return;
} }
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
if (records.length === 0) { if (records.length === 0) {
toast.error(t('sessions.sidebar.session.export.nothingToExport')); toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return; return;
@@ -556,7 +550,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
downloadAsMarkdown(markdown, filename); downloadAsMarkdown(markdown, filename);
toast.success(t('sessions.sidebar.session.export.success')); toast.success(t('sessions.sidebar.session.export.success'));
showSkippedSubtasksWarning(skippedSubtaskCount); 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 () => { const handleExportSession = React.useCallback(async () => {
if (node.children.length > 0) { if (node.children.length > 0) {
setExportIncludeSubtasks(true); setExportIncludeSubtasks(true);
@@ -20,6 +20,26 @@ describe("useProjectsStore settings synchronization", () => {
}) })
}) })
describe("useProjectsStore selection identity", () => {
test("changes only the active project id", () => {
const first = { id: "project-a", path: "/repo-a", lastOpenedAt: 10 } as ProjectEntry
const second = { id: "project-b", path: "/repo-b", lastOpenedAt: 20 } as ProjectEntry
const projects = [first, second]
useProjectsStore.setState({
projects,
activeProjectId: first.id,
manualProjectOrder: projects.map((project) => project.id),
})
useProjectsStore.getState().setActiveProjectIdOnly(second.id)
const state = useProjectsStore.getState()
expect(state.activeProjectId).toBe(second.id)
expect(state.projects).toBe(projects)
expect(state.projects.map((project) => project.lastOpenedAt)).toEqual([10, 20])
})
})
describe("useProjectsStore default model and thinking level", () => { describe("useProjectsStore default model and thinking level", () => {
const seed = (project: ProjectEntry) => { const seed = (project: ProjectEntry) => {
useProjectsStore.setState({ useProjectsStore.setState({
+14 -16
View File
@@ -357,13 +357,7 @@ const readPersistedActiveProjectId = (): string | null => {
return null; return null;
}; };
const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => { const cacheActiveProjectId = (activeProjectId: string | null) => {
try {
safeStorage.setItem(getProjectsStorageKey(), JSON.stringify(projects));
} catch {
// ignored
}
try { try {
const activeProjectStorageKey = getActiveProjectStorageKey(); const activeProjectStorageKey = getActiveProjectStorageKey();
if (activeProjectId) { if (activeProjectId) {
@@ -376,6 +370,15 @@ const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null)
} }
}; };
const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => {
try {
safeStorage.setItem(getProjectsStorageKey(), JSON.stringify(projects));
} catch {
// ignored
}
cacheActiveProjectId(activeProjectId);
};
const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null, manualOrder?: string[]) => { const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null, manualOrder?: string[]) => {
cacheProjects(projects, activeProjectId); cacheProjects(projects, activeProjectId);
if (manualOrder) { if (manualOrder) {
@@ -693,18 +696,13 @@ export const useProjectsStore = create<ProjectsStore>()(
if (activeProjectId === id) { if (activeProjectId === id) {
return; return;
} }
const target = projects.find((project) => project.id === id); if (!projects.some((project) => project.id === id)) {
if (!target) {
return; return;
} }
const now = Date.now(); set({ activeProjectId: id });
const nextProjects = projects.map((project) => cacheActiveProjectId(id);
project.id === id ? { ...project, lastOpenedAt: now } : project void updateDesktopSettings({ activeProjectId: id });
);
set({ projects: nextProjects, activeProjectId: id });
persistProjects(nextProjects, id, get().manualProjectOrder);
}, },
renameProject: (id: string, label: string) => { renameProject: (id: string, label: string) => {
+16 -3
View File
@@ -2845,13 +2845,25 @@ export function useScopedBlockingQuestions(sessionID: string | null, directory?:
return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS) return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS)
} }
const sessionsByIdCache = new WeakMap<State["session"], Map<string, Session>>()
const getSessionById = (sessions: State["session"], sessionID?: string | null): Session | undefined => {
if (!sessionID) return undefined
let sessionsById = sessionsByIdCache.get(sessions)
if (!sessionsById) {
sessionsById = new Map(sessions.map((session) => [session.id, session]))
sessionsByIdCache.set(sessions, sessionsById)
}
return sessionsById.get(sessionID)
}
export function useParentSession(sessionID: string | null, directory?: string): Session | null { export function useParentSession(sessionID: string | null, directory?: string): Session | null {
return useDirectorySync( return useDirectorySync(
useCallback((state: State) => { useCallback((state: State) => {
if (!sessionID) return null if (!sessionID) return null
const current = state.session.find((s) => s.id === sessionID) const current = getSessionById(state.session, sessionID)
if (!current?.parentID) return null if (!current?.parentID) return null
return state.session.find((s) => s.id === current.parentID) return getSessionById(state.session, current.parentID)
?? getAllSyncSessions().find((s) => s.id === current.parentID) ?? getAllSyncSessions().find((s) => s.id === current.parentID)
?? null ?? null
}, [sessionID]), }, [sessionID]),
@@ -2864,7 +2876,8 @@ export function useSession(sessionID?: string | null, directory?: string) {
const { childStores } = useSyncSystem() const { childStores } = useSyncSystem()
const getSnapshot = useCallback(() => { const getSnapshot = useCallback(() => {
if (directory) { if (directory) {
return childStores.getChild(directory)?.getState().session.find((session) => session.id === sessionID) const sessions = childStores.getChild(directory)?.getState().session
return sessions ? getSessionById(sessions, sessionID) : undefined
} }
return findLiveSession(getLiveStates(childStores), sessionID) return findLiveSession(getLiveStates(childStores), sessionID)
}, [childStores, directory, sessionID]) }, [childStores, directory, sessionID])
+15
View File
@@ -14,6 +14,7 @@ import {
useSyncSDK, useSyncSDK,
useSyncRuntime, useSyncRuntime,
resyncBlockingRequestsForDirectory, resyncBlockingRequestsForDirectory,
buildSessionMessageRecordsSnapshot,
} from "./sync-context" } from "./sync-context"
import { stripSessionDiffSnapshots } from "./sanitize" import { stripSessionDiffSnapshots } from "./sanitize"
import { isVSCodeRuntime } from "@/lib/desktop" import { isVSCodeRuntime } from "@/lib/desktop"
@@ -412,3 +413,17 @@ export function usePrefetchSessionMessages() {
touch(sessionID, directory) touch(sessionID, directory)
}, [messageLoader, runtimeKey, touch]) }, [messageLoader, runtimeKey, touch])
} }
export function useSessionMessageRecordsForExport() {
const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
const touch = useSessionCacheTouch()
return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
if (getRuntimeKey() !== runtimeKey) return null
const store = childStores.ensureChild(directory, { bootstrap: false })
touch(sessionID, directory)
await messageLoader.loadComplete({ directory, sessionID })
if (getRuntimeKey() !== runtimeKey) return null
return buildSessionMessageRecordsSnapshot(store.getState(), sessionID).list
}, [childStores, messageLoader, runtimeKey, touch])
}