perf: reduce UI render fanout and scroll jitter
- Cut broad render fanout across the app by replacing shared-store whole-object subscriptions with leaf selectors, memoizing hot chrome boundaries, and isolating disabled global providers from live session/message state. This keeps header controls, composer toolbars, side panels, and other non-hot UI surfaces from repainting on every assistant update or keystroke. - Rework sidebar session ordering so recent, project groups, and worktree groups derive from one ordering source while avoiding streaming-time thrash. The sidebar now uses a stabilized session snapshot, preserves structural identity for unchanged rows, reads live row status/details per session, and applies a one-shot sort bump on idle->busy instead of continuously resorting during activity. - Fix chat/input scroll instability by separating viewport-resize handling from message-growth handling, disabling conflicting native scroll anchoring, and stopping textarea autosize from collapsing on every growth keystroke. This removes the multiline typing jiggle during streaming and reduces unnecessary composer rerenders. - Also gate voice context wiring behind voice-mode enablement and codify the learned render/scroll/order anti-patterns in AGENTS.md so future changes avoid the same classes of regressions.
This commit is contained in:
@@ -8,8 +8,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { formatDirectoryName, cn } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
import { useSessions, useDirectorySync, useAllSessionStatuses } from '@/sync/sync-context';
|
||||
import { useSidebarSessions, useAllSessionStatuses } from '@/sync/sync-context';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
||||
@@ -57,6 +56,7 @@ import {
|
||||
} from './sidebar/ConfirmDialogs';
|
||||
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
||||
import {
|
||||
type ActiveNowEntry,
|
||||
addActiveNowSession,
|
||||
deriveActiveNowSessions,
|
||||
persistActiveNowEntries,
|
||||
@@ -68,7 +68,7 @@ import {
|
||||
formatProjectLabel,
|
||||
normalizePath,
|
||||
} from './sidebar/utils';
|
||||
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
|
||||
@@ -113,6 +113,50 @@ interface SessionSidebarProps {
|
||||
showOnlyMainWorkspace?: boolean;
|
||||
}
|
||||
|
||||
type SessionStatusActivityBridgeProps = {
|
||||
safeStorage: Storage;
|
||||
setActiveNowEntries: React.Dispatch<React.SetStateAction<ActiveNowEntry[]>>;
|
||||
};
|
||||
|
||||
const SessionStatusActivityBridge: React.FC<SessionStatusActivityBridgeProps> = ({
|
||||
safeStorage,
|
||||
setActiveNowEntries,
|
||||
}) => {
|
||||
const globalSessionStatuses = useAllSessionStatuses();
|
||||
const sessionStatus = React.useMemo(
|
||||
() => new Map(Object.entries(globalSessionStatuses)),
|
||||
[globalSessionStatuses],
|
||||
);
|
||||
|
||||
const previousStreamingIdsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextStreamingIds = new Set<string>();
|
||||
sessionStatus.forEach((status, sessionId) => {
|
||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
||||
nextStreamingIds.add(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
const previousStreamingIds = previousStreamingIdsRef.current;
|
||||
const startedStreamingIds = Array.from(nextStreamingIds).filter((sessionId) => !previousStreamingIds.has(sessionId));
|
||||
if (startedStreamingIds.length > 0) {
|
||||
setActiveNowEntries((prev) => {
|
||||
const next = startedStreamingIds.reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev);
|
||||
if (next === prev) {
|
||||
return prev;
|
||||
}
|
||||
persistActiveNowEntries(safeStorage, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
previousStreamingIdsRef.current = nextStreamingIds;
|
||||
}, [sessionStatus, safeStorage, setActiveNowEntries]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
mobileVariant = false,
|
||||
onSessionSelected,
|
||||
@@ -137,7 +181,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
|
||||
@@ -267,10 +310,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const gitBranches = useGitAllBranches();
|
||||
|
||||
const sync = useSync();
|
||||
const syncSessions = useSessions();
|
||||
const syncSessions = useSidebarSessions();
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
const sessionsByDirectory = useGlobalSessionsStore((state) => state.sessionsByDirectory);
|
||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
@@ -278,37 +320,54 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
||||
const shareSession = useSessionUIStore((state) => state.shareSession);
|
||||
const unshareSession = useSessionUIStore((state) => state.unshareSession);
|
||||
const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState);
|
||||
const globalSessionStatuses = useAllSessionStatuses();
|
||||
// sessionAttentionStates removed — now using notification-store directly in SessionNodeItem
|
||||
const permissionsRecord = useDirectorySync((state) => state.permission);
|
||||
|
||||
const sessionStatus = React.useMemo(
|
||||
() => new Map(Object.entries(globalSessionStatuses)),
|
||||
[globalSessionStatuses],
|
||||
);
|
||||
const permissions = React.useMemo(
|
||||
() => new Map(Object.entries(permissionsRecord)),
|
||||
[permissionsRecord],
|
||||
);
|
||||
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const updateStore = useUpdateStore();
|
||||
|
||||
const sessions = React.useMemo(
|
||||
() => (hasLoadedGlobalSessions ? globalActiveSessions : syncSessions),
|
||||
[globalActiveSessions, hasLoadedGlobalSessions, syncSessions],
|
||||
);
|
||||
const sessions = React.useMemo(() => {
|
||||
if (!hasLoadedGlobalSessions) {
|
||||
return syncSessions;
|
||||
}
|
||||
|
||||
const syncSessionSignature = React.useMemo(
|
||||
if (syncSessions.length === 0) {
|
||||
return globalActiveSessions;
|
||||
}
|
||||
|
||||
const syncedById = new Map(syncSessions.map((session) => [session.id, session]));
|
||||
const merged = globalActiveSessions.map((session) => syncedById.get(session.id) ?? session);
|
||||
const seenIds = new Set(merged.map((session) => session.id));
|
||||
|
||||
syncSessions.forEach((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionDirectory = resolveGlobalSessionDirectory(session);
|
||||
if (sessionDirectory && sessionDirectory === currentDirectory) {
|
||||
merged.push(session);
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
}, [currentDirectory, globalActiveSessions, hasLoadedGlobalSessions, syncSessions]);
|
||||
|
||||
const syncSessionStructureSignature = React.useMemo(
|
||||
() => syncSessions
|
||||
.map((session) => `${session.id}:${session.time?.updated ?? session.time?.created ?? 0}:${session.time?.archived ? 1 : 0}`)
|
||||
.map((session) => {
|
||||
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
|
||||
return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`;
|
||||
})
|
||||
.join('|'),
|
||||
[syncSessions],
|
||||
);
|
||||
|
||||
const syncSessionsSnapshotRef = React.useRef<Session[]>(syncSessions);
|
||||
React.useEffect(() => {
|
||||
syncSessionsSnapshotRef.current = syncSessions;
|
||||
}, [syncSessionStructureSignature, syncSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -346,13 +405,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
void refreshGlobalSessions(syncSessions);
|
||||
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
|
||||
void discoverWorktrees();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDirectory, syncSessionSignature, syncSessions]);
|
||||
}, [currentDirectory, syncSessionStructureSignature]);
|
||||
|
||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||
@@ -489,6 +548,11 @@ 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])),
|
||||
[sortedSessions],
|
||||
);
|
||||
|
||||
const allKnownSessionsById = React.useMemo(() => {
|
||||
const next = new Map<string, Session>();
|
||||
[...sessions, ...archivedSessions].forEach((session) => {
|
||||
@@ -506,77 +570,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
persistActiveNowEntries(safeStorage, pruned);
|
||||
}, [activeNowEntries, allKnownSessionsById, safeStorage]);
|
||||
|
||||
const previousStreamingIdsRef = React.useRef<Set<string>>(new Set());
|
||||
React.useEffect(() => {
|
||||
const nextStreamingIds = new Set<string>();
|
||||
sessionStatus?.forEach((status, sessionId) => {
|
||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
||||
nextStreamingIds.add(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
const previousStreamingIds = previousStreamingIdsRef.current;
|
||||
const startedStreamingIds = Array.from(nextStreamingIds).filter((sessionId) => !previousStreamingIds.has(sessionId));
|
||||
if (startedStreamingIds.length > 0) {
|
||||
setActiveNowEntries((prev) => {
|
||||
const next = startedStreamingIds.reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev);
|
||||
if (next === prev) {
|
||||
return prev;
|
||||
}
|
||||
persistActiveNowEntries(safeStorage, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
previousStreamingIdsRef.current = nextStreamingIds;
|
||||
}, [sessionStatus, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const busyIds: string[] = [];
|
||||
sessionStatus?.forEach((status, sessionId) => {
|
||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
||||
busyIds.push(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (busyIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveNowEntries((prev) => {
|
||||
const known = new Set(prev.map((entry) => entry.sessionId));
|
||||
let next = prev;
|
||||
let changed = false;
|
||||
|
||||
busyIds.forEach((sessionId) => {
|
||||
if (known.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = allKnownSessionsById.get(sessionId);
|
||||
if (!session || session.time?.archived) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSubtask = Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
if (isSubtask) {
|
||||
return;
|
||||
}
|
||||
|
||||
next = addActiveNowSession(next, sessionId);
|
||||
known.add(sessionId);
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
persistActiveNowEntries(safeStorage, next);
|
||||
return next;
|
||||
});
|
||||
}, [sessionStatus, allKnownSessionsById, safeStorage]);
|
||||
|
||||
const childrenMap = React.useMemo(() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
sortedSessions.forEach((session) => {
|
||||
@@ -887,8 +880,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
isVSCode,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
sessionsByDirectory,
|
||||
getSessionsByDirectory,
|
||||
availableWorktreesByProject,
|
||||
});
|
||||
|
||||
@@ -1248,15 +1239,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
projectId={projectId}
|
||||
archivedBucket={archivedBucket}
|
||||
directoryStatus={directoryStatus}
|
||||
sessionMemoryState={sessionMemoryState as Map<string, { isZombie?: boolean }>}
|
||||
currentSessionId={currentSessionId}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
sessionStatus={sessionStatus as Map<string, { type?: string }> | undefined}
|
||||
permissions={permissions as Map<string, unknown[]>}
|
||||
editingId={editingId}
|
||||
setEditingId={setEditingId}
|
||||
editTitle={editTitle}
|
||||
@@ -1289,15 +1277,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
),
|
||||
[
|
||||
directoryStatus,
|
||||
sessionMemoryState,
|
||||
currentSessionId,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
notifyOnSubtasks,
|
||||
sessionStatus,
|
||||
permissions,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
@@ -1395,6 +1380,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setRenameFolderDraft={setRenameFolderDraft}
|
||||
setRenamingFolderId={setRenamingFolderId}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
sessionOrderIndex={sessionOrderIndex}
|
||||
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
|
||||
onToggleCollapsedGroup={toggleCollapsedGroup}
|
||||
dragHandleProps={dragHandleProps}
|
||||
@@ -1428,6 +1414,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
renamingFolderId,
|
||||
renameFolderDraft,
|
||||
pinnedSessionIds,
|
||||
sessionOrderIndex,
|
||||
prVisualStateByDirectoryBranch,
|
||||
toggleCollapsedGroup,
|
||||
],
|
||||
@@ -1490,6 +1477,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SessionStatusActivityBridge
|
||||
safeStorage={safeStorage}
|
||||
setActiveNowEntries={setActiveNowEntries}
|
||||
/>
|
||||
|
||||
<SidebarHeader
|
||||
hideDirectoryControls={hideDirectoryControls}
|
||||
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
||||
@@ -1530,8 +1522,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
collapsedProjects={collapsedProjects}
|
||||
hideDirectoryControls={hideDirectoryControls}
|
||||
projectRepoStatus={projectRepoStatus}
|
||||
hoveredProjectId={hoveredProjectId}
|
||||
setHoveredProjectId={setHoveredProjectId}
|
||||
isDesktopShellRuntime={isDesktopShellRuntime}
|
||||
stuckProjectHeaders={stuckProjectHeaders}
|
||||
mobileVariant={mobileVariant}
|
||||
|
||||
@@ -66,6 +66,7 @@ type Props = {
|
||||
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
prVisualStateByDirectoryBranch: Map<string, {
|
||||
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
number: number;
|
||||
@@ -130,12 +131,24 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
setRenameFolderDraft,
|
||||
setRenamingFolderId,
|
||||
pinnedSessionIds,
|
||||
sessionOrderIndex,
|
||||
prVisualStateByDirectoryBranch,
|
||||
onToggleCollapsedGroup,
|
||||
dragHandleProps,
|
||||
compactBodyPadding = false,
|
||||
} = props;
|
||||
|
||||
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
|
||||
const aIndex = sessionOrderIndex.get(a.session.id);
|
||||
const bIndex = sessionOrderIndex.get(b.session.id);
|
||||
if (aIndex !== undefined || bIndex !== undefined) {
|
||||
if (aIndex === undefined) return 1;
|
||||
if (bIndex === undefined) return -1;
|
||||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||
}
|
||||
return compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds);
|
||||
}, [pinnedSessionIds, sessionOrderIndex]);
|
||||
|
||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const isMinimalMode = displayMode === 'minimal';
|
||||
@@ -144,7 +157,11 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
const maxVisible = hideDirectoryControls ? 10 : 5;
|
||||
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
||||
const shouldFilterGroupContents = hasSessionSearchQuery;
|
||||
const sourceGroupNodes = shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions;
|
||||
const sourceGroupNodes = React.useMemo(
|
||||
() => [...(shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions)]
|
||||
.sort(compareSessionNodes),
|
||||
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
|
||||
);
|
||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
||||
const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : [];
|
||||
|
||||
@@ -163,7 +180,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
const nodes = folder.sessionIds
|
||||
.map((sid) => nodeBySessionId.get(sid))
|
||||
.filter((n): n is SessionNode => Boolean(n))
|
||||
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds));
|
||||
.sort(compareSessionNodes);
|
||||
return { folder, nodes };
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import type { SessionNode, SessionSummaryMeta } from './types';
|
||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
|
||||
@@ -60,15 +62,12 @@ type Props = {
|
||||
projectId?: string | null;
|
||||
archivedBucket?: boolean;
|
||||
directoryStatus: Map<string, 'unknown' | 'exists' | 'missing'>;
|
||||
sessionMemoryState: Map<string, { isZombie?: boolean }>;
|
||||
currentSessionId: string | null;
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
notifyOnSubtasks: boolean;
|
||||
sessionStatus?: Map<string, { type?: string }>;
|
||||
permissions: Map<string, unknown[]>;
|
||||
editingId: string | null;
|
||||
setEditingId: (id: string | null) => void;
|
||||
editTitle: string;
|
||||
@@ -99,7 +98,59 @@ type Props = {
|
||||
renderContext?: 'project' | 'recent';
|
||||
};
|
||||
|
||||
export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
const getNodeChildSignature = (node: SessionNode): string => {
|
||||
if (node.children.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return node.children
|
||||
.map((child) => `${child.session.id}:${child.children.length}`)
|
||||
.join('|');
|
||||
};
|
||||
|
||||
const areEqual = (prev: Props, next: Props): boolean => {
|
||||
const prevSession = prev.node.session;
|
||||
const nextSession = next.node.session;
|
||||
const prevSessionId = prevSession.id;
|
||||
const nextSessionId = nextSession.id;
|
||||
|
||||
if (prevSessionId !== nextSessionId) return false;
|
||||
if (prev.node.session !== next.node.session) return false;
|
||||
if (getNodeChildSignature(prev.node) !== getNodeChildSignature(next.node)) return false;
|
||||
if (prev.depth !== next.depth) return false;
|
||||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||
if (prev.projectId !== next.projectId) return false;
|
||||
if (prev.archivedBucket !== next.archivedBucket) return false;
|
||||
if ((prev.currentSessionId === prevSessionId) !== (next.currentSessionId === nextSessionId)) return false;
|
||||
if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
|
||||
if (prev.expandedParents.has(prevSessionId) !== next.expandedParents.has(nextSessionId)) return false;
|
||||
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
||||
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
|
||||
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
|
||||
if ((prev.editingId === prevSessionId) !== (next.editingId === nextSessionId)) return false;
|
||||
if (prev.editTitle !== next.editTitle && ((prev.editingId === prevSessionId) || (next.editingId === nextSessionId))) return false;
|
||||
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
|
||||
|
||||
const prevMenuKey = `${prev.renderContext ?? 'project'}:${prev.archivedBucket ? 'archived' : 'active'}:${prevSessionId}`;
|
||||
const nextMenuKey = `${next.renderContext ?? 'project'}:${next.archivedBucket ? 'archived' : 'active'}:${nextSessionId}`;
|
||||
if ((prev.openSidebarMenuKey === prevMenuKey) !== (next.openSidebarMenuKey === nextMenuKey)) return false;
|
||||
|
||||
const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(prev.groupDirectory ?? null);
|
||||
const nextDirectory = normalizePath((nextSession as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(next.groupDirectory ?? null);
|
||||
if (prevDirectory !== nextDirectory) return false;
|
||||
if ((prevDirectory ? prev.directoryStatus.get(prevDirectory) : null) !== (nextDirectory ? next.directoryStatus.get(nextDirectory) : null)) return false;
|
||||
|
||||
if ((prev.secondaryMeta?.projectLabel ?? null) !== (next.secondaryMeta?.projectLabel ?? null)) return false;
|
||||
if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false;
|
||||
if (prev.mobileVariant !== next.mobileVariant) return false;
|
||||
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const {
|
||||
node,
|
||||
depth = 0,
|
||||
@@ -107,15 +158,12 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
projectId,
|
||||
archivedBucket = false,
|
||||
directoryStatus,
|
||||
sessionMemoryState,
|
||||
currentSessionId,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
notifyOnSubtasks,
|
||||
sessionStatus,
|
||||
permissions,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
@@ -163,24 +211,30 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
const suppressNextSelectRef = React.useRef(false);
|
||||
|
||||
const session = node.session;
|
||||
const liveSession = useSession(session.id);
|
||||
const resolvedSession = liveSession ?? session;
|
||||
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
const isZombie = useViewportStore(
|
||||
React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]),
|
||||
);
|
||||
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
||||
const isMissingDirectory = directoryState === 'missing';
|
||||
const memoryState = sessionMemoryState.get(session.id);
|
||||
const isActive = currentSessionId === session.id;
|
||||
const sessionTitle = session.title || 'Untitled Session';
|
||||
const sessionTitle = resolvedSession.title || 'Untitled Session';
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||||
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
|
||||
const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
|
||||
const unseenCount = useSessionUnseenCount(session.id);
|
||||
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
||||
const sessionSummary = session.summary as SessionSummaryMeta | undefined;
|
||||
const sessionSummary = resolvedSession.summary as SessionSummaryMeta | undefined;
|
||||
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
|
||||
const sessionTimestamp = session.time?.updated || session.time?.created || Date.now();
|
||||
const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now();
|
||||
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
||||
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||
@@ -236,9 +290,9 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
|
||||
const statusType = sessionStatus?.type ?? 'idle';
|
||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||
const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
|
||||
const pendingPermissionCount = sessionPermissions.length;
|
||||
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||
const statusMarkerContent = isStreaming
|
||||
@@ -296,7 +350,7 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const streamingIndicator = memoryState?.isZombie
|
||||
const streamingIndicator = isZombie
|
||||
? <RiErrorWarningLine className="h-4 w-4 text-status-warning" />
|
||||
: null;
|
||||
|
||||
@@ -338,14 +392,14 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
|
||||
{isPinnedSession ? 'Unpin session' : 'Pin session'}
|
||||
</DropdownMenuItem>
|
||||
{!session.share ? (
|
||||
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
|
||||
{!resolvedSession.share ? (
|
||||
<DropdownMenuItem onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
|
||||
<RiShare2Line className="mr-1 h-4 w-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1">
|
||||
<DropdownMenuItem onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
|
||||
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
|
||||
@@ -601,3 +655,5 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual);
|
||||
|
||||
@@ -43,8 +43,6 @@ type Props = {
|
||||
collapsedProjects: Set<string>;
|
||||
hideDirectoryControls: boolean;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
hoveredProjectId: string | null;
|
||||
setHoveredProjectId: (id: string | null) => void;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stuckProjectHeaders: Set<string>;
|
||||
mobileVariant: boolean;
|
||||
@@ -144,7 +142,6 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||
const isActiveProject = projectKey === props.activeProjectId;
|
||||
const isHovered = props.hoveredProjectId === projectKey;
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
|
||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||
@@ -164,14 +161,12 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isActiveProject={isActiveProject}
|
||||
isHovered={isHovered}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
onToggle={() => props.toggleProject(projectKey)}
|
||||
onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
|
||||
@@ -8,8 +8,6 @@ type Args = {
|
||||
isVSCode: boolean;
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
sessionsByDirectory: Map<string, Session[]>;
|
||||
getSessionsByDirectory: (directory: string) => Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
||||
};
|
||||
|
||||
@@ -18,11 +16,25 @@ export const useProjectSessionLists = (args: Args) => {
|
||||
isVSCode,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
sessionsByDirectory,
|
||||
getSessionsByDirectory,
|
||||
availableWorktreesByProject,
|
||||
} = args;
|
||||
|
||||
const sessionsByDirectory = React.useMemo(() => {
|
||||
const next = new Map<string, Session[]>();
|
||||
sessions.forEach((session) => {
|
||||
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = next.get(directory) ?? [];
|
||||
collection.push(session);
|
||||
next.set(directory, collection);
|
||||
});
|
||||
return next;
|
||||
}, [sessions]);
|
||||
|
||||
const getSessionsForProject = React.useCallback(
|
||||
(project: { normalizedPath: string }) => {
|
||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
@@ -37,7 +49,7 @@ export const useProjectSessionLists = (args: Args) => {
|
||||
const collected: Session[] = [];
|
||||
|
||||
directories.forEach((directory) => {
|
||||
const sessionsForDirectory = sessionsByDirectory.get(directory) ?? getSessionsByDirectory(directory);
|
||||
const sessionsForDirectory = sessionsByDirectory.get(directory) ?? [];
|
||||
sessionsForDirectory.forEach((session) => {
|
||||
if (seen.has(session.id)) {
|
||||
return;
|
||||
@@ -49,7 +61,7 @@ export const useProjectSessionLists = (args: Args) => {
|
||||
|
||||
return collected;
|
||||
},
|
||||
[availableWorktreesByProject, getSessionsByDirectory, isVSCode, sessionsByDirectory],
|
||||
[availableWorktreesByProject, isVSCode, sessionsByDirectory],
|
||||
);
|
||||
|
||||
const getArchivedSessionsForProject = React.useCallback(
|
||||
|
||||
@@ -32,14 +32,12 @@ export interface SortableProjectItemProps {
|
||||
projectIconBackground?: string;
|
||||
isCollapsed: boolean;
|
||||
isActiveProject: boolean;
|
||||
isHovered: boolean;
|
||||
isRepo: boolean;
|
||||
isDesktopShell: boolean;
|
||||
isStuck: boolean;
|
||||
hideDirectoryControls: boolean;
|
||||
mobileVariant: boolean;
|
||||
onToggle: () => void;
|
||||
onHoverChange: (hovered: boolean) => void;
|
||||
onNewSession: () => void;
|
||||
onNewWorktreeSession?: () => void;
|
||||
onRenameStart: () => void;
|
||||
@@ -67,14 +65,12 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
projectIconBackground,
|
||||
isCollapsed,
|
||||
isActiveProject,
|
||||
isHovered,
|
||||
isRepo,
|
||||
isDesktopShell,
|
||||
isStuck,
|
||||
hideDirectoryControls,
|
||||
mobileVariant,
|
||||
onToggle,
|
||||
onHoverChange,
|
||||
onNewSession,
|
||||
onNewWorktreeSession,
|
||||
onRenameStart,
|
||||
@@ -158,8 +154,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
'w-full text-left group/project select-none',
|
||||
)}
|
||||
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
|
||||
onMouseEnter={() => onHoverChange(true)}
|
||||
onMouseLeave={() => onHoverChange(false)}
|
||||
>
|
||||
<div className="relative flex items-center gap-1 px-0.5 py-0.5" {...attributes}>
|
||||
<Tooltip delayDuration={1500}>
|
||||
@@ -172,17 +166,17 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
className={cn(
|
||||
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
|
||||
isRepo && !hideDirectoryControls
|
||||
? (mobileVariant ? 'pr-20' : isHovered ? 'pr-20' : 'pr-7')
|
||||
: (mobileVariant ? 'pr-14' : isHovered ? 'pr-14' : 'pr-7'),
|
||||
? (mobileVariant ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
||||
: (mobileVariant ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<span className={cn('hidden text-muted-foreground h-3.5 w-3.5 items-center justify-center', isHovered && 'inline-flex')}>
|
||||
<span className="hidden h-3.5 w-3.5 items-center justify-center text-muted-foreground group-hover/project:inline-flex group-focus-within/project:inline-flex">
|
||||
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
{imageUrl ? (
|
||||
<span
|
||||
className={cn('inline-flex h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px]', isHovered && 'hidden')}
|
||||
className="inline-flex h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px] group-hover/project:hidden group-focus-within/project:hidden"
|
||||
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
|
||||
>
|
||||
<img
|
||||
@@ -194,9 +188,9 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
/>
|
||||
</span>
|
||||
) : ProjectIcon ? (
|
||||
<ProjectIcon className={cn('h-3.5 w-3.5', isHovered && 'hidden')} style={iconColor ? { color: iconColor } : undefined} />
|
||||
<ProjectIcon className="h-3.5 w-3.5 group-hover/project:hidden group-focus-within/project:hidden" style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<RiFolderLine className={cn('h-3.5 w-3.5 text-muted-foreground/80', isHovered && 'hidden')} style={iconColor ? { color: iconColor } : undefined} />
|
||||
<RiFolderLine className="h-3.5 w-3.5 text-muted-foreground/80 group-hover/project:hidden group-focus-within/project:hidden" style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
@@ -227,7 +221,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground transition-opacity',
|
||||
mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
mobileVariant ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
|
||||
)}
|
||||
aria-label="New worktree"
|
||||
>
|
||||
@@ -249,7 +243,11 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
||||
isMenuOpen ? 'opacity-100 pointer-events-auto' : mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
isMenuOpen
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: mobileVariant
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
|
||||
)}
|
||||
aria-label="Project menu"
|
||||
onClick={handleMenuTriggerClick}
|
||||
@@ -291,7 +289,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
mobileVariant ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
|
||||
)}
|
||||
aria-label={isRepo ? 'New draft session' : 'New session'}
|
||||
>
|
||||
|
||||
@@ -133,6 +133,20 @@ export const compareSessionsByPinnedAndTime = (
|
||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||
};
|
||||
|
||||
export const compareSessionsByPinnedAndCreated = (
|
||||
a: Session,
|
||||
b: Session,
|
||||
pinnedSessionIds: Set<string>,
|
||||
): number => {
|
||||
const aPinned = pinnedSessionIds.has(a.id);
|
||||
const bPinned = pinnedSessionIds.has(b.id);
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
||||
};
|
||||
|
||||
export const dedupeSessionsById = (sessions: Session[]): Session[] => {
|
||||
const byId = new Map<string, Session>();
|
||||
sessions.forEach((session) => {
|
||||
|
||||
Reference in New Issue
Block a user