From fa1503a038a759a6aa7c0800d93d3c527851a1b7 Mon Sep 17 00:00:00 2001 From: c_w_xiaohei <1641233466@qq.com> Date: Tue, 25 Aug 2026 17:26:32 +0800 Subject: [PATCH] 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. --- .../sidebar/list/SessionProjectCollection.tsx | 2 -- .../SessionGroupSection.behavior.test.tsx | 1 - .../sidebar/projects/SessionGroupSection.tsx | 1 - .../projects/SessionProjectScroller.tsx | 1 - .../sidebar/sessions/SessionNodeItem.tsx | 24 ++++++--------- .../ui/src/stores/useProjectsStore.test.ts | 20 +++++++++++++ packages/ui/src/stores/useProjectsStore.ts | 30 +++++++++---------- packages/ui/src/sync/sync-context.tsx | 19 ++++++++++-- packages/ui/src/sync/use-sync.ts | 15 ++++++++++ 9 files changed, 74 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx index 70c8d0be..81b9d9c4 100644 --- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx +++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx @@ -286,7 +286,6 @@ const VisibleSessionProjects: React.FC = ({ topol hideDirectoryControls: view.hideDirectoryControls, mobileVariant: view.mobileVariant, alwaysShowActions, - activeProjectId: view.activeProjectId, notifyOnSubtasks, pinnedSessionIds: collection.pinnedSessionIds, sessionOrderIndex, @@ -330,7 +329,6 @@ const VisibleSessionProjects: React.FC = ({ topol setCopiedSessionId, rowActions, toggleParent, - view.activeProjectId, view.hideDirectoryControls, view.hasSessionSearchQuery, view.mobileVariant, diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx index bb14a6e1..1b91bfbd 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx @@ -113,7 +113,6 @@ const createProps = (): SessionGroupSectionProps => ({ resetGroupSessionLimit: () => undefined, mobileVariant: false, alwaysShowActions: false, - activeProjectId: 'project', setActiveProjectIdOnly: () => undefined, setActiveMainTab: () => undefined, setSessionSwitcherOpen: () => undefined, diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx index f1cb3711..7a571e30 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx @@ -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 diff --git a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx index 30a31ac3..7450ad32 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx @@ -58,7 +58,6 @@ type SessionProjectScrollerGroupProps = Pick & { - activeProjectId: string | null; pinnedSessionIds: Set; sessionOrderIndex: Map; }; diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index a5646b4a..3c9d40c5 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -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); diff --git a/packages/ui/src/stores/useProjectsStore.test.ts b/packages/ui/src/stores/useProjectsStore.test.ts index f82d2c54..34c43ea2 100644 --- a/packages/ui/src/stores/useProjectsStore.test.ts +++ b/packages/ui/src/stores/useProjectsStore.test.ts @@ -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", () => { const seed = (project: ProjectEntry) => { useProjectsStore.setState({ diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 1ab2c9f5..045ff1e2 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -357,13 +357,7 @@ const readPersistedActiveProjectId = (): string | null => { return null; }; -const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => { - try { - safeStorage.setItem(getProjectsStorageKey(), JSON.stringify(projects)); - } catch { - // ignored - } - +const cacheActiveProjectId = (activeProjectId: string | null) => { try { const activeProjectStorageKey = getActiveProjectStorageKey(); 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[]) => { cacheProjects(projects, activeProjectId); if (manualOrder) { @@ -693,18 +696,13 @@ export const useProjectsStore = create()( if (activeProjectId === id) { return; } - const target = projects.find((project) => project.id === id); - if (!target) { + if (!projects.some((project) => project.id === id)) { return; } - const now = Date.now(); - const nextProjects = projects.map((project) => - project.id === id ? { ...project, lastOpenedAt: now } : project - ); - - set({ projects: nextProjects, activeProjectId: id }); - persistProjects(nextProjects, id, get().manualProjectOrder); + set({ activeProjectId: id }); + cacheActiveProjectId(id); + void updateDesktopSettings({ activeProjectId: id }); }, renameProject: (id: string, label: string) => { diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index ac0a6b69..1face78c 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -2845,13 +2845,25 @@ export function useScopedBlockingQuestions(sessionID: string | null, directory?: return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS) } +const sessionsByIdCache = new WeakMap>() + +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 { return useDirectorySync( useCallback((state: State) => { if (!sessionID) return null - const current = state.session.find((s) => s.id === sessionID) + const current = getSessionById(state.session, sessionID) 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) ?? null }, [sessionID]), @@ -2864,7 +2876,8 @@ export function useSession(sessionID?: string | null, directory?: string) { const { childStores } = useSyncSystem() const getSnapshot = useCallback(() => { 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) }, [childStores, directory, sessionID]) diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index e5ea35e6..a2bb5d65 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -14,6 +14,7 @@ import { useSyncSDK, useSyncRuntime, resyncBlockingRequestsForDirectory, + buildSessionMessageRecordsSnapshot, } from "./sync-context" import { stripSessionDiffSnapshots } from "./sanitize" import { isVSCodeRuntime } from "@/lib/desktop" @@ -412,3 +413,17 @@ export function usePrefetchSessionMessages() { touch(sessionID, directory) }, [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]) +}