fix: make session ordering follow activity lifecycle

Session lists now promote a conversation when it starts working and again when it settles, instead of reacting to every streaming timestamp update. This keeps ordering responsive without bringing back the sidebar churn removed by the recent performance work.

Apply the same user-visible order across Recent, project and worktree groups, session switchers, mobile navigation, widgets, the command palette, and the desktop tray. Preserve pinned priority, freeze timestamp fallback ordering, and keep child-session activity scoped to siblings under the same parent so it never moves the root conversation.

Seed reconnect snapshots without synthetic jumps, clear ephemeral ranks on deletion and runtime changes, and cover lifecycle transitions, mixed root/child trees, metadata-only updates, and project-group ordering with regression tests.
This commit is contained in:
Bohdan Triapitsyn
2026-07-23 15:45:53 +03:00
parent dac8166937
commit 89f7c37d60
20 changed files with 556 additions and 152 deletions
@@ -67,12 +67,17 @@ import {
} from './sidebar/activitySections';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import {
compareSessionsByPinnedAndTime,
formatProjectLabel,
normalizePath,
selectExpandedParentKeysForContext,
toggleExpandedParentKey,
} from './sidebar/utils';
import {
compareSessionsByLifecycleOrder,
EMPTY_SESSION_ORDER_RANKS,
orderSessionsByLifecycleScopes,
useSessionOrderingStore,
} from '@/sync/session-ordering';
import {
refreshGlobalSessions,
refreshGlobalSessionsForDirectories,
@@ -261,6 +266,10 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const sessionOrderRanks = useSessionOrderingStore(React.useCallback(
(state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS,
[isVisible],
));
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
try {
@@ -613,6 +622,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
homeDirectory,
worktreeMetadata,
pinnedSessionIds,
sessionOrderRanks,
gitBranches,
isVSCode,
});
@@ -632,20 +642,18 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
setCollapsedProjects,
});
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
const orderedSessions = React.useMemo(() => {
return orderSessionsByLifecycleScopes(sessions, pinnedSessionIds, sessionOrderRanks);
}, [pinnedSessionIds, sessionOrderRanks, sessions]);
// 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
// Reuse the index while the ordered IDs stay unchanged.
// Without this, a fresh `orderedSessions` 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],
() => orderedSessions.map((session) => session.id).join('|'),
[orderedSessions],
);
const sessionOrderIndexRef = React.useRef<{ signature: string; map: Map<string, number> } | null>(null);
@@ -654,14 +662,14 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
if (cached && cached.signature === sessionOrderSignature) {
return cached.map;
}
const next = new Map(sortedSessions.map((session, index) => [session.id, index]));
const next = new Map(orderedSessions.map((session, index) => [session.id, index]));
sessionOrderIndexRef.current = { signature: sessionOrderSignature, map: next };
return next;
}, [sessionOrderSignature, sortedSessions]);
}, [orderedSessions, sessionOrderSignature]);
const childrenMap = React.useMemo(() => {
const map = new Map<string, Session[]>();
sortedSessions.forEach((session) => {
orderedSessions.forEach((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
if (!parentID) {
return;
@@ -670,9 +678,9 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
collection.push(session);
map.set(parentID, collection);
});
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
map.forEach((list) => list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)));
return map;
}, [sortedSessions, pinnedSessionIds]);
}, [orderedSessions, pinnedSessionIds, sessionOrderRanks]);
const emptyState = React.useMemo(() => (
<div className="py-6 text-center text-muted-foreground">
@@ -1070,6 +1078,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
worktreeMetadata,
availableWorktreesByProject,
pinnedSessionIds,
sessionOrderRanks,
foldersMap,
collapsedFolderIds,
gitBranches,
@@ -1260,8 +1269,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
}
return deriveRecentSessions(sessions)
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [isVSCode, pinnedSessionIds, sessions, showRecentSection]);
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
}, [isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]);
// Prefetch is wired below, after recentSessionIds is computed.
@@ -1718,7 +1727,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
/>
<SessionPrefetchEffect
enabled={isVisible}
sortedSessions={sortedSessions}
sortedSessions={orderedSessions}
recentSessions={activeNowSessions}
prefetchSession={sync.prefetchSession}
/>