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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
|
||||
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
|
||||
- `sessionBootstrapDemands.ts`: Builds the deduplicated directory demand plan. Selected directories rank above active projects, expanded groups, visible collapsed groups, and background/collapsed projects.
|
||||
- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting).
|
||||
- `utils.tsx`: Shared sidebar utilities (path normalization, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting). Shared session ranking lives in `sync/session-ordering.ts`.
|
||||
|
||||
## Loading rules
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract.
|
||||
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
|
||||
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
|
||||
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes are read from the authoritative snapshot on the next sidebar render rather than triggering a full tree rebuild themselves.
|
||||
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent.
|
||||
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
|
||||
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
||||
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
||||
|
||||
@@ -17,7 +17,8 @@ import { SessionFolderItem } from '../SessionFolderItem';
|
||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
||||
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering';
|
||||
import {
|
||||
collectSubtreeContainingId,
|
||||
computeNodeStructureKey,
|
||||
@@ -342,7 +343,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
if (bIndex === undefined) return -1;
|
||||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||
}
|
||||
return compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds);
|
||||
return compareSessionsByLifecycleOrder(a.session, b.session, pinnedSessionIds, EMPTY_SESSION_ORDER_RANKS);
|
||||
}, [pinnedSessionIds, sessionOrderIndex]);
|
||||
|
||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||
|
||||
@@ -22,13 +22,10 @@ const getSessionUpdatedAt = (session: Session): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
|
||||
return [...sessions].sort((a, b) => getSessionUpdatedAt(b) - getSessionUpdatedAt(a));
|
||||
};
|
||||
|
||||
// Recent sessions are simply every non-archived, top-level session updated
|
||||
// within the last RECENT_SESSION_MAX_AGE_MS. No persisted history or live-busy
|
||||
// tracking — membership is derived directly from session timestamps.
|
||||
// tracking: membership is timestamp-derived, while the caller applies shared
|
||||
// lifecycle ordering.
|
||||
export const deriveRecentSessions = (
|
||||
sessions: Session[],
|
||||
now = Date.now(),
|
||||
@@ -40,5 +37,5 @@ export const deriveRecentSessions = (
|
||||
}
|
||||
return getSessionUpdatedAt(session) >= minUpdatedAt;
|
||||
});
|
||||
return sortSessionsByUpdated(recent);
|
||||
return recent;
|
||||
};
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { SessionGroup, SessionNode } from '../types';
|
||||
import {
|
||||
compareSessionsByPinnedAndTime,
|
||||
dedupeSessionsById,
|
||||
getArchivedScopeKey,
|
||||
normalizeForBranchComparison,
|
||||
normalizePath,
|
||||
} from '../utils';
|
||||
import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from '@/sync/session-ordering';
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
@@ -17,6 +17,7 @@ type Args = {
|
||||
homeDirectory: string | null;
|
||||
worktreeMetadata: Map<string, WorktreeMetadata>;
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderRanks: ReadonlyMap<string, number>;
|
||||
gitBranches: Map<string, string | null>;
|
||||
isVSCode: boolean;
|
||||
};
|
||||
@@ -68,7 +69,7 @@ export const useSessionGrouping = (args: Args) => {
|
||||
) => {
|
||||
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
||||
const sortedProjectSessions = dedupeSessionsById(projectSessions)
|
||||
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds));
|
||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks));
|
||||
|
||||
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
@@ -83,7 +84,7 @@ export const useSessionGrouping = (args: Args) => {
|
||||
collection.push(session);
|
||||
childrenMap.set(parentID, collection);
|
||||
});
|
||||
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds)));
|
||||
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks)));
|
||||
|
||||
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
||||
availableWorktrees.forEach((meta) => {
|
||||
@@ -160,15 +161,15 @@ export const useSessionGrouping = (args: Args) => {
|
||||
sessions: groupedNodes.get(rootKey) ?? [],
|
||||
}];
|
||||
|
||||
// Calculate activity info for each worktree to determine sorting priority
|
||||
// Calculate display-order activity for each worktree.
|
||||
const worktreeActivityInfo = new Map<string, { hasActiveSession: boolean; lastUpdatedAt: number }>();
|
||||
availableWorktrees.forEach((meta) => {
|
||||
const directory = normalizePath(meta.path) ?? meta.path;
|
||||
const sessionsInWorktree = groupedNodes.get(directory) ?? [];
|
||||
const hasActiveSession = sessionsInWorktree.length > 0;
|
||||
// Calculate the latest update time among all sessions in this worktree
|
||||
// Lifecycle rank wins when present; timestamps seed bootstrap ordering.
|
||||
const lastUpdatedAt = sessionsInWorktree.reduce((max, node) => {
|
||||
const updatedAt = Number(node.session.time?.updated ?? node.session.time?.created ?? 0);
|
||||
const updatedAt = getSessionLifecycleOrderValue(node.session, args.sessionOrderRanks);
|
||||
if (!Number.isFinite(updatedAt)) {
|
||||
return max;
|
||||
}
|
||||
@@ -178,7 +179,7 @@ export const useSessionGrouping = (args: Args) => {
|
||||
worktreeActivityInfo.set(directory, { hasActiveSession, lastUpdatedAt });
|
||||
});
|
||||
|
||||
// Sort worktrees: active first (by last updated desc), then inactive (by label asc)
|
||||
// Sort populated worktrees by shared session activity, then empty ones by label.
|
||||
const sortedWorktrees = [...availableWorktrees].sort((a, b) => {
|
||||
const aDir = normalizePath(a.path) ?? a.path;
|
||||
const bDir = normalizePath(b.path) ?? b.path;
|
||||
@@ -190,7 +191,7 @@ export const useSessionGrouping = (args: Args) => {
|
||||
return aInfo.hasActiveSession ? -1 : 1;
|
||||
}
|
||||
|
||||
// Second priority: for active worktrees, sort by last updated (desc)
|
||||
// Second priority: for populated worktrees, sort by latest display activity.
|
||||
if (aInfo.hasActiveSession && bInfo.hasActiveSession) {
|
||||
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
|
||||
}
|
||||
@@ -246,7 +247,7 @@ export const useSessionGrouping = (args: Args) => {
|
||||
|
||||
return groups;
|
||||
},
|
||||
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitBranches, args.isVSCode, t],
|
||||
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
import { useGitAllBranches } from '@/stores/useGitStore';
|
||||
import type { SessionNode } from '../types';
|
||||
import { compareSessionsByPinnedAndTime, isPathWithinProject } from '../utils';
|
||||
import { isPathWithinProject } from '../utils';
|
||||
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
|
||||
|
||||
export type SwitcherItem = {
|
||||
node: SessionNode;
|
||||
@@ -44,6 +45,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
||||
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||
const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById);
|
||||
const branchesByDirectory = useGitAllBranches();
|
||||
|
||||
const normalizedProjects = React.useMemo(
|
||||
@@ -80,7 +82,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
||||
}
|
||||
}
|
||||
childrenByParent.forEach((list) => {
|
||||
list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||
list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
||||
});
|
||||
|
||||
const parents = activeSessions
|
||||
@@ -91,7 +93,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
||||
const directory = resolveGlobalSessionDirectory(session);
|
||||
return findProjectForDirectory(directory)?.id === scopeProjectId;
|
||||
})
|
||||
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds))
|
||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks))
|
||||
.slice(0, MAX_PARENT_SESSIONS);
|
||||
|
||||
const buildNode = (session: Session): SessionNode => {
|
||||
@@ -118,7 +120,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds, scopeProjectId]);
|
||||
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds, scopeProjectId, sessionOrderRanks]);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { isSessionPinned } from '@/stores/useSessionPinnedStore';
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
|
||||
|
||||
@@ -131,45 +129,6 @@ export const isBranchDifferentFromLabel = (branch: string | null, label: string)
|
||||
return normalizeForBranchComparison(branch) !== normalizeForBranchComparison(label);
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getSessionCreatedAt = (session: Session): number => {
|
||||
return toFiniteNumber(session.time?.created) ?? 0;
|
||||
};
|
||||
|
||||
const getSessionUpdatedAt = (session: Session): number => {
|
||||
return toFiniteNumber(session.time?.updated) ?? toFiniteNumber(session.time?.created) ?? 0;
|
||||
};
|
||||
|
||||
export const compareSessionsByPinnedAndTime = (
|
||||
a: Session,
|
||||
b: Session,
|
||||
pinnedSessionIds: Set<string>,
|
||||
): number => {
|
||||
const aPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(a), a.id);
|
||||
const bPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(b), b.id);
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
if (aPinned && bPinned) {
|
||||
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
||||
}
|
||||
|
||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||
};
|
||||
|
||||
export const dedupeSessionsById = (sessions: Session[]): Session[] => {
|
||||
const byId = new Map<string, Session>();
|
||||
sessions.forEach((session) => {
|
||||
|
||||
Reference in New Issue
Block a user