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:
@@ -41,9 +41,11 @@ import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
|
||||
import { SidebarFooter } from './sidebar/SidebarFooter';
|
||||
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
|
||||
import { SessionNodeItem } from './sidebar/SessionNodeItem';
|
||||
import type { SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { SortableDragHandleProps } from './sidebar/sortableItems';
|
||||
import {
|
||||
@@ -55,7 +57,7 @@ import {
|
||||
type DeleteSessionConfirmState,
|
||||
} from './sidebar/ConfirmDialogs';
|
||||
import { BulkActionBar } from './sidebar/BulkActionBar';
|
||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||
import { useSidebarBulkActions } from './sidebar/hooks/useSidebarBulkActions';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
||||
import {
|
||||
@@ -153,6 +155,14 @@ const isKnownActiveSessionDirectory = (
|
||||
|
||||
const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
|
||||
|
||||
const EMPTY_SUBTREE_SET: Set<string> = new Set();
|
||||
|
||||
const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...args: Args) => Return): ((...args: Args) => Return) => {
|
||||
const handlerRef = React.useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
return React.useCallback((...args: Args) => handlerRef.current(...args), []);
|
||||
};
|
||||
|
||||
interface SessionSidebarProps {
|
||||
mobileVariant?: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
@@ -383,6 +393,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
syncSessionsSnapshotRef.current = liveSessions;
|
||||
}, [syncSessionStructureSignature, liveSessions]);
|
||||
|
||||
// Batched live-session index. Building this here turns the per-row
|
||||
// `useSession(session.id)` reads in SessionNodeItem (each of which
|
||||
// iterates all child-stores via `findLiveSession`) into a single
|
||||
// Map lookup. With M visible rows, that changes an O(M × child-stores)
|
||||
// work to O(child-stores) once per Sidebar render.
|
||||
const liveSessionById = React.useMemo(
|
||||
() => new Map(liveSessions.map((session) => [session.id, session] as const)),
|
||||
[liveSessions],
|
||||
);
|
||||
|
||||
const projectWorktreeDiscoveryKey = React.useMemo(
|
||||
() => projects
|
||||
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
|
||||
@@ -399,6 +419,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
|
||||
}, []);
|
||||
|
||||
// Tracks the last project list we already kicked off discovery for.
|
||||
// A re-mount with the same project set shouldn't fan out another
|
||||
// burst of `checkIsGitRepository` / `listProjectWorktrees` calls.
|
||||
const discoveredProjectsRef = React.useRef<string>('');
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -409,24 +433,39 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||
const allWorktrees: WorktreeMetadata[] = [];
|
||||
|
||||
await Promise.all(
|
||||
projectEntries.map(async (project) => {
|
||||
// Constrain fanout: previously `Promise.all(projects.map(...))` could
|
||||
// spawn dozens of concurrent `git worktree list` and
|
||||
// `checkIsGitRepository` calls on cold start, each touching the
|
||||
// worktree process. Concurrency=3 keeps startup latency low while
|
||||
// bounding peak worktree-process load.
|
||||
const worktreeConcurrency = 3;
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: worktreeConcurrency }, async () => {
|
||||
while (true) {
|
||||
const nextIndex = cursor;
|
||||
cursor += 1;
|
||||
if (nextIndex >= projectEntries.length) return;
|
||||
const project = projectEntries[nextIndex];
|
||||
const projectPath = normalizePath(project.path);
|
||||
if (!projectPath) return;
|
||||
if (!projectPath) continue;
|
||||
try {
|
||||
// Use store-cached isGitRepo when available; fall back to direct check for initial worktree discovery
|
||||
// Use store-cached isGitRepo when available; fall back to
|
||||
// a direct check for projects the Git store hasn't seen yet.
|
||||
// Forcing `ensureStatus` here also warms the store so the
|
||||
// PR/render paths downstream can read isGitRepo for free.
|
||||
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
|
||||
const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then(m => m.checkIsGitRepository(projectPath));
|
||||
if (!isGitRepo) return;
|
||||
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
|
||||
if (!isGitRepo) continue;
|
||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
if (cancelled || worktrees.length === 0) return;
|
||||
if (cancelled || worktrees.length === 0) continue;
|
||||
worktreesByProject.set(projectPath, worktrees);
|
||||
allWorktrees.push(...worktrees);
|
||||
} catch {
|
||||
// ignore discovery errors
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -436,6 +475,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Skip if we already discovered worktrees for this exact project set.
|
||||
if (discoveredProjectsRef.current === projectWorktreeDiscoveryKey) {
|
||||
return;
|
||||
}
|
||||
discoveredProjectsRef.current = projectWorktreeDiscoveryKey;
|
||||
void discoverWorktrees();
|
||||
|
||||
return () => {
|
||||
@@ -508,11 +552,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||
}, [sessions, pinnedSessionIds]);
|
||||
|
||||
const sessionOrderIndex = React.useMemo(
|
||||
() => new Map(sortedSessions.map((session, index) => [session.id, index])),
|
||||
// Stable signature: id + updatedAt joined. When this string is
|
||||
// unchanged, the relative ordering of sessions is identical and the
|
||||
// derived `sessionOrderIndex` Map can return the previous reference.
|
||||
// Without this, a fresh `sortedSessions` array (cheap to rebuild) would
|
||||
// still hand a new Map identity to the entire SessionGroupSection
|
||||
// memo chain, invalidating sourceGroupNodes, nodeBySessionId, and the
|
||||
// rest of the down-stream useMemo chain.
|
||||
const sessionOrderSignature = React.useMemo(
|
||||
() => sortedSessions.map((s) => `${s.id}:${s.time?.updated ?? 0}`).join('|'),
|
||||
[sortedSessions],
|
||||
);
|
||||
|
||||
const sessionOrderIndexRef = React.useRef<{ signature: string; map: Map<string, number> } | null>(null);
|
||||
const sessionOrderIndex = React.useMemo(() => {
|
||||
const cached = sessionOrderIndexRef.current;
|
||||
if (cached && cached.signature === sessionOrderSignature) {
|
||||
return cached.map;
|
||||
}
|
||||
const next = new Map(sortedSessions.map((session, index) => [session.id, index]));
|
||||
sessionOrderIndexRef.current = { signature: sessionOrderSignature, map: next };
|
||||
return next;
|
||||
}, [sessionOrderSignature, sortedSessions]);
|
||||
|
||||
const childrenMap = React.useMemo(() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
sortedSessions.forEach((session) => {
|
||||
@@ -703,6 +765,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
[collapsedFolderIds, toggleFolderCollapse, createFolder, t],
|
||||
);
|
||||
|
||||
const stableHandleSessionSelect = useStableRenderCallback(handleSessionSelect);
|
||||
const stableHandleSessionDoubleClick = useStableRenderCallback(handleSessionDoubleClick);
|
||||
const stableHandleSaveEdit = useStableRenderCallback(handleSaveEdit);
|
||||
const stableHandleCancelEdit = useStableRenderCallback(handleCancelEdit);
|
||||
const stableHandleShareSession = useStableRenderCallback(handleShareSession);
|
||||
const stableHandleCopyShareUrl = useStableRenderCallback(handleCopyShareUrl);
|
||||
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
|
||||
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
|
||||
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
|
||||
|
||||
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
|
||||
setVisibleSessionCountByGroup((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -881,6 +953,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
normalizedProjects,
|
||||
});
|
||||
|
||||
useArchivedAutoFolders({
|
||||
@@ -930,7 +1003,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const { currentSessionDirectory } = useProjectSessionSelection({
|
||||
useProjectSessionSelection({
|
||||
projectSections,
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
@@ -942,8 +1015,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
openNewSessionDraft,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
sessions,
|
||||
worktreeMetadata,
|
||||
});
|
||||
|
||||
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
|
||||
@@ -1214,15 +1285,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
projectHeaderSentinelRefs,
|
||||
});
|
||||
|
||||
const renderSessionNode = React.useCallback(
|
||||
const renderSessionNode = useStableRenderCallback(
|
||||
(
|
||||
node: SessionNode,
|
||||
depth = 0,
|
||||
depth: number = 0,
|
||||
groupDirectory?: string | null,
|
||||
projectId?: string | null,
|
||||
archivedBucket = false,
|
||||
archivedBucket: boolean = false,
|
||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
|
||||
renderContext: 'project' | 'recent' = 'project',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
): React.ReactNode => (
|
||||
<SessionNodeItem
|
||||
node={node}
|
||||
@@ -1240,16 +1312,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setEditingId={setEditingId}
|
||||
editTitle={editTitle}
|
||||
setEditTitle={setEditTitle}
|
||||
handleSaveEdit={handleSaveEdit}
|
||||
handleCancelEdit={handleCancelEdit}
|
||||
handleSaveEdit={stableHandleSaveEdit}
|
||||
handleCancelEdit={stableHandleCancelEdit}
|
||||
toggleParent={toggleParent}
|
||||
handleSessionSelect={handleSessionSelect}
|
||||
handleSessionDoubleClick={handleSessionDoubleClick}
|
||||
handleSessionSelect={stableHandleSessionSelect}
|
||||
handleSessionDoubleClick={stableHandleSessionDoubleClick}
|
||||
togglePinnedSession={togglePinnedSession}
|
||||
handleShareSession={handleShareSession}
|
||||
handleShareSession={stableHandleShareSession}
|
||||
copiedSessionId={copiedSessionId}
|
||||
handleCopyShareUrl={handleCopyShareUrl}
|
||||
handleUnshareSession={handleUnshareSession}
|
||||
handleCopyShareUrl={stableHandleCopyShareUrl}
|
||||
handleUnshareSession={stableHandleUnshareSession}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
renamingFolderId={renamingFolderId}
|
||||
@@ -1257,50 +1329,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
getSessionFolderId={getSessionFolderId}
|
||||
removeSessionFromFolder={removeSessionFromFolder}
|
||||
addSessionToFolder={addSessionToFolder}
|
||||
createFolderAndStartRename={createFolderAndStartRename}
|
||||
createFolderAndStartRename={stableCreateFolderAndStartRename}
|
||||
openContextPanelTab={openContextPanelTab}
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
handleDeleteSession={stableHandleDeleteSession}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowSidebarActions}
|
||||
renderSessionNode={renderSessionNode}
|
||||
secondaryMeta={secondaryMeta}
|
||||
renderContext={renderContext}
|
||||
subtreeContainsActive={renderExtras?.subtreeContainsActive ?? EMPTY_SUBTREE_SET}
|
||||
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET}
|
||||
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
|
||||
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
|
||||
childRenderExtrasFor={renderExtras?.childRenderExtrasFor}
|
||||
liveSessionById={liveSessionById}
|
||||
/>
|
||||
),
|
||||
[
|
||||
currentSessionId,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
notifyOnSubtasks,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
handleSaveEdit,
|
||||
handleCancelEdit,
|
||||
toggleParent,
|
||||
handleSessionSelect,
|
||||
handleSessionDoubleClick,
|
||||
togglePinnedSession,
|
||||
handleShareSession,
|
||||
copiedSessionId,
|
||||
handleCopyShareUrl,
|
||||
handleUnshareSession,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
renamingFolderId,
|
||||
getFoldersForScope,
|
||||
getSessionFolderId,
|
||||
removeSessionFromFolder,
|
||||
addSessionToFolder,
|
||||
createFolderAndStartRename,
|
||||
openContextPanelTab,
|
||||
handleDeleteSession,
|
||||
mobileVariant,
|
||||
alwaysShowSidebarActions,
|
||||
],
|
||||
);
|
||||
|
||||
const toggleCollapsedGroup = React.useCallback((key: string) => {
|
||||
@@ -1335,7 +1379,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}, [prVisualSummaryMap]);
|
||||
|
||||
const renderGroupSessions = React.useCallback(
|
||||
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => (
|
||||
(
|
||||
group: SessionGroup,
|
||||
groupKey: string,
|
||||
projectId?: string | null,
|
||||
hideGroupLabel?: boolean,
|
||||
dragHandleProps?: SortableDragHandleProps | null,
|
||||
compactBodyPadding?: boolean,
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>,
|
||||
) => (
|
||||
<SessionGroupSection
|
||||
group={group}
|
||||
groupKey={groupKey}
|
||||
@@ -1355,7 +1407,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
showDeletionDialog={showDeletionDialog}
|
||||
setDeleteFolderConfirm={setDeleteFolderConfirm}
|
||||
renderSessionNode={renderSessionNode}
|
||||
currentSessionDirectory={currentSessionDirectory}
|
||||
projectRepoStatus={projectRepoStatus}
|
||||
lastRepoStatus={lastRepoStatusRef.current}
|
||||
showMoreGroupSessions={showMoreGroupSessions}
|
||||
@@ -1368,16 +1419,23 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
||||
openNewSessionDraft={openNewSessionDraftFromTree}
|
||||
addSessionToFolder={addSessionToFolder}
|
||||
createFolderAndStartRename={createFolderAndStartRename}
|
||||
createFolderAndStartRename={stableCreateFolderAndStartRename}
|
||||
renamingFolderId={renamingFolderId}
|
||||
renameFolderDraft={renameFolderDraft}
|
||||
setRenameFolderDraft={setRenameFolderDraft}
|
||||
setRenamingFolderId={setRenamingFolderId}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
sessionOrderIndex={sessionOrderIndex}
|
||||
currentSessionId={currentSessionId}
|
||||
editingId={editingId}
|
||||
editTitle={editTitle}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
liveSessionById={liveSessionById}
|
||||
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
|
||||
onToggleCollapsedGroup={toggleCollapsedGroup}
|
||||
dragHandleProps={dragHandleProps}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -1393,7 +1451,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
deleteFolder,
|
||||
showDeletionDialog,
|
||||
renderSessionNode,
|
||||
currentSessionDirectory,
|
||||
projectRepoStatus,
|
||||
showMoreGroupSessions,
|
||||
resetGroupSessionLimit,
|
||||
@@ -1405,11 +1462,17 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setSessionSwitcherOpen,
|
||||
openNewSessionDraftFromTree,
|
||||
addSessionToFolder,
|
||||
createFolderAndStartRename,
|
||||
stableCreateFolderAndStartRename,
|
||||
renamingFolderId,
|
||||
renameFolderDraft,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
sessionOrderIndex,
|
||||
currentSessionId,
|
||||
editingId,
|
||||
editTitle,
|
||||
openSidebarMenuKey,
|
||||
liveSessionById,
|
||||
prVisualStateByDirectoryBranch,
|
||||
toggleCollapsedGroup,
|
||||
],
|
||||
@@ -1419,174 +1482,40 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<SidebarActivitySections
|
||||
sections={activitySections}
|
||||
renderSessionNode={renderSessionNode}
|
||||
currentSessionId={currentSessionId}
|
||||
editingId={editingId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
variant="section"
|
||||
/>
|
||||
) : null;
|
||||
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
|
||||
|
||||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds);
|
||||
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey);
|
||||
const multiSelectStoreApi = useSessionMultiSelectStore;
|
||||
|
||||
const handleToggleSelectionMode = React.useCallback(() => {
|
||||
useSessionMultiSelectStore.getState().toggleMode();
|
||||
}, []);
|
||||
const handleExitSelectionMode = React.useCallback(() => {
|
||||
useSessionMultiSelectStore.getState().disable();
|
||||
}, []);
|
||||
|
||||
const bulkScopeIsArchived = React.useMemo(() => {
|
||||
if (selectedIds.size === 0) return false;
|
||||
if (typeof document === 'undefined') return false;
|
||||
let sawActive = false;
|
||||
let sawArchived = false;
|
||||
for (const id of selectedIds) {
|
||||
const rows = document.querySelectorAll<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
|
||||
for (const row of rows) {
|
||||
if (row.getAttribute('data-session-archived') === '1') sawArchived = true;
|
||||
else sawActive = true;
|
||||
}
|
||||
}
|
||||
return sawArchived && !sawActive;
|
||||
}, [selectedIds]);
|
||||
|
||||
const derivedSelectionScope = React.useMemo(() => {
|
||||
if (selectionScopeKey) return selectionScopeKey;
|
||||
if (selectedIds.size === 0) return null;
|
||||
if (typeof document === 'undefined') return null;
|
||||
for (const id of selectedIds) {
|
||||
const row = document.querySelector<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
|
||||
const scope = row?.getAttribute('data-session-scope');
|
||||
if (scope && scope.length > 0) return scope;
|
||||
}
|
||||
return null;
|
||||
}, [selectedIds, selectionScopeKey]);
|
||||
|
||||
const bulkScopeFolders = React.useMemo(() => {
|
||||
if (!derivedSelectionScope) return [];
|
||||
return foldersMap[derivedSelectionScope] ?? [];
|
||||
}, [foldersMap, derivedSelectionScope]);
|
||||
|
||||
const bulkCanRemoveFromFolder = React.useMemo(() => {
|
||||
if (!derivedSelectionScope || selectedIds.size === 0) return false;
|
||||
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
|
||||
for (const folder of scopeFolders) {
|
||||
for (const id of folder.sessionIds) {
|
||||
if (selectedIds.has(id)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [foldersMap, derivedSelectionScope, selectedIds]);
|
||||
|
||||
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
|
||||
if (!derivedSelectionScope || selectedIds.size === 0) return;
|
||||
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
|
||||
}, [addSessionsToFolder, selectedIds, derivedSelectionScope]);
|
||||
|
||||
const handleBulkCreateFolderAndMove = React.useCallback(() => {
|
||||
if (!derivedSelectionScope || selectedIds.size === 0) return;
|
||||
const newFolder = createFolderAndStartRename(derivedSelectionScope);
|
||||
if (!newFolder) return;
|
||||
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
|
||||
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope]);
|
||||
|
||||
const handleBulkRemoveFromFolder = React.useCallback(() => {
|
||||
if (!derivedSelectionScope || selectedIds.size === 0) return;
|
||||
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
|
||||
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope]);
|
||||
|
||||
const executeBulkDelete = React.useCallback(async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
if (bulkScopeIsArchived) {
|
||||
const { deletedIds, failedIds } = await deleteSessions(ids);
|
||||
if (deletedIds.length > 0) {
|
||||
toast.success(deletedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(failedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
|
||||
}
|
||||
} else {
|
||||
const { archivedIds, failedIds } = await archiveSessions(ids);
|
||||
if (archivedIds.length > 0) {
|
||||
toast.success(archivedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(failedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
|
||||
}
|
||||
}
|
||||
useSessionMultiSelectStore.getState().clear();
|
||||
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
|
||||
|
||||
const handleBulkDelete = React.useCallback(() => {
|
||||
const count = selectedIds.size;
|
||||
if (count === 0) return;
|
||||
if (!showDeletionDialog) {
|
||||
void executeBulkDelete();
|
||||
return;
|
||||
}
|
||||
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
|
||||
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog]);
|
||||
|
||||
const confirmBulkDelete = React.useCallback(async () => {
|
||||
setBulkDeleteConfirm(null);
|
||||
await executeBulkDelete();
|
||||
}, [executeBulkDelete]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectionModeEnabled) return;
|
||||
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
if (isInlineEditing) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
const modifier = isMac ? event.metaKey : event.ctrlKey;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
useSessionMultiSelectStore.getState().disable();
|
||||
return;
|
||||
}
|
||||
if (modifier && event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
handleBulkDelete();
|
||||
return;
|
||||
}
|
||||
if (modifier && (event.key === 'a' || event.key === 'A')) {
|
||||
const rows = typeof document !== 'undefined'
|
||||
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
|
||||
: [];
|
||||
if (rows.length === 0) return;
|
||||
event.preventDefault();
|
||||
const currentScope = multiSelectStoreApi.getState().scopeKey;
|
||||
const targetScope = currentScope
|
||||
?? rows[0]?.getAttribute('data-session-scope')
|
||||
?? null;
|
||||
const scopeFilter = (el: HTMLElement): boolean => {
|
||||
if (!targetScope) return true;
|
||||
return el.getAttribute('data-session-scope') === targetScope;
|
||||
};
|
||||
const ids = rows
|
||||
.filter(scopeFilter)
|
||||
.map((el) => el.getAttribute('data-session-row'))
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
if (ids.length === 0) return;
|
||||
multiSelectStoreApi.getState().replaceAll(ids, targetScope || null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', listener);
|
||||
return () => window.removeEventListener('keydown', listener);
|
||||
}, [handleBulkDelete, isInlineEditing, multiSelectStoreApi, selectionModeEnabled]);
|
||||
const {
|
||||
selectionModeEnabled,
|
||||
hasSelection,
|
||||
selectedIdsSize,
|
||||
bulkScopeIsArchived,
|
||||
derivedSelectionScope,
|
||||
bulkScopeFolders,
|
||||
bulkCanRemoveFromFolder,
|
||||
handleToggleSelectionMode,
|
||||
handleExitSelectionMode,
|
||||
handleBulkMoveToFolder,
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
confirmBulkDelete,
|
||||
} = useSidebarBulkActions({
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
});
|
||||
const handleOpenMultiRunFromHeader = React.useCallback(() => {
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
@@ -1670,9 +1599,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
isInlineEditing={isInlineEditing}
|
||||
/>
|
||||
|
||||
{selectionModeEnabled && selectedIds.size > 0 ? (
|
||||
{selectionModeEnabled && hasSelection ? (
|
||||
<BulkActionBar
|
||||
selectedCount={selectedIds.size}
|
||||
selectedCount={selectedIdsSize}
|
||||
scopeKey={derivedSelectionScope}
|
||||
scopeFolders={bulkScopeFolders}
|
||||
archivedBucket={bulkScopeIsArchived}
|
||||
|
||||
Reference in New Issue
Block a user