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:
@@ -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,
|
||||||
|
|||||||
-1
@@ -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({
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user