From 89f7c37d60edb564dbf85e8137e5183fef9cb01c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 23 Jul 2026 15:45:53 +0300 Subject: [PATCH] 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. --- packages/ui/src/apps/MobileSessionsSheet.tsx | 32 ++- packages/ui/src/apps/mobileWidgetSnapshot.ts | 16 +- packages/ui/src/apps/runtimeEndpointReset.ts | 2 + .../ui/src/apps/useEdgeSwipeSessionSwitch.ts | 17 +- .../chat/MobileSessionStatusBar.tsx | 46 ++-- .../src/components/session/SessionSidebar.tsx | 45 ++-- .../session/sidebar/DOCUMENTATION.md | 4 +- .../session/sidebar/SessionGroupSection.tsx | 5 +- .../session/sidebar/activitySections.ts | 9 +- .../sidebar/hooks/useSessionGrouping.ts | 19 +- .../session/sidebar/hooks/useSwitcherItems.ts | 10 +- .../src/components/session/sidebar/utils.tsx | 41 ---- .../ui/src/components/ui/CommandPalette.tsx | 31 ++- packages/ui/src/hooks/useTraySync.ts | 20 +- packages/ui/src/stores/DOCUMENTATION.md | 2 + packages/ui/src/sync/DOCUMENTATION.md | 5 +- .../ui/src/sync/global-session-status.test.ts | 29 +++ packages/ui/src/sync/global-session-status.ts | 34 ++- packages/ui/src/sync/session-ordering.test.ts | 138 ++++++++++++ packages/ui/src/sync/session-ordering.ts | 203 ++++++++++++++++++ 20 files changed, 556 insertions(+), 152 deletions(-) create mode 100644 packages/ui/src/sync/session-ordering.test.ts create mode 100644 packages/ui/src/sync/session-ordering.ts diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index c0815aa8..df895017 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -49,7 +49,13 @@ import { mergeLiveSessionWithGlobalSession, refreshGlobalSessions, useGlobalSess import { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore'; import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore'; +import { + EMPTY_SESSION_ORDER_RANKS, + orderSessionsByLifecycleScopes, + useSessionOrderingStore, +} from '@/sync/session-ordering'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useAllLiveSessions } from '@/sync/sync-context'; import type { WorktreeMetadata } from '@/types/worktree'; @@ -65,6 +71,8 @@ type MobileSessionsSheetProps = { variant?: 'sheet' | 'sidebar'; }; +const EMPTY_PINNED_SESSION_IDS = new Set(); + type ProjectMeta = { id: string; label: string; @@ -519,6 +527,14 @@ export const MobileSessionsSheet: React.FC = ({ open, const { git } = useRuntimeAPIs(); const liveSessions = useAllLiveSessions(); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); + const pinnedSessionIds = useSessionPinnedStore(React.useCallback( + (state) => open || variant === 'sidebar' ? state.ids : EMPTY_PINNED_SESSION_IDS, + [open, variant], + )); + const sessionOrderRanks = useSessionOrderingStore(React.useCallback( + (state) => open || variant === 'sidebar' ? state.rankById : EMPTY_SESSION_ORDER_RANKS, + [open, variant], + )); const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); @@ -691,7 +707,7 @@ export const MobileSessionsSheet: React.FC = ({ open, for (const node of nodes) { for (const bucket of node.buckets) { - bucket.sessions.sort((a, b) => getSessionTimestamp(b) - getSessionTimestamp(a)); + bucket.sessions = orderSessionsByLifecycleScopes(bucket.sessions, pinnedSessionIds, sessionOrderRanks); for (const session of bucket.sessions) { if (!getParentId(session)) node.totalSessions += 1; } @@ -699,7 +715,7 @@ export const MobileSessionsSheet: React.FC = ({ open, } return nodes; - }, [activeProjectId, projectsMeta, sessions]); + }, [activeProjectId, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]); const normalizedDirectory = normalizePath(currentDirectory); @@ -923,14 +939,16 @@ export const MobileSessionsSheet: React.FC = ({ open, // Flat lists used only by the dedicated search-results view. const searchSessionMatches = React.useMemo(() => { if (!normalizedQuery) return [] as Session[]; - return sessions - .filter((session) => { + return orderSessionsByLifecycleScopes( + sessions.filter((session) => { const directory = getSessionDirectory(session); const project = findExactProjectMatch(projectsMeta, directory); return sessionMatchesQuery(session, project?.label ?? '', normalizedQuery); - }) - .sort((a, b) => getSessionTimestamp(b) - getSessionTimestamp(a)); - }, [normalizedQuery, projectsMeta, sessions]); + }), + pinnedSessionIds, + sessionOrderRanks, + ); + }, [normalizedQuery, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]); const searchProjectMatches = React.useMemo(() => { if (!normalizedQuery) return [] as Array; diff --git a/packages/ui/src/apps/mobileWidgetSnapshot.ts b/packages/ui/src/apps/mobileWidgetSnapshot.ts index eed0e318..4b40597c 100644 --- a/packages/ui/src/apps/mobileWidgetSnapshot.ts +++ b/packages/ui/src/apps/mobileWidgetSnapshot.ts @@ -4,7 +4,9 @@ import type { ProjectEntry } from '@/lib/api/types'; import { useUIStore } from '@/stores/useUIStore'; import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { useNotificationStore } from '@/sync/notification-store'; +import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering'; import { getRuntimeKey } from '@/lib/runtime-switch'; /** @@ -31,7 +33,7 @@ export interface MobileWidgetSnapshot { runtimeKey: string; /** Count of sessions needing attention — same signal that drives the app-icon badge. */ attentionCount: number; - /** Most-recently-updated top-level sessions, newest first (capped for the medium widget). */ + /** Top-level sessions in the app's shared lifecycle order (capped for the medium widget). */ recentSessions: MobileWidgetSession[]; } @@ -73,9 +75,11 @@ export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => { const unseenBySession = useNotificationStore.getState().index.session.unseenCount; const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks; const projects = useProjectsStore.getState().projects; + const pinnedSessionIds = useSessionPinnedStore.getState().ids; + const sessionOrderRanks = useSessionOrderingStore.getState().rankById; let attentionCount = 0; - const topLevel: Array<{ id: string; title: string; updated: number; unread: boolean; project: string }> = []; + const topLevel: Array<{ session: Session; unread: boolean; project: string }> = []; for (const session of sessions) { const isSubtask = parentIdOf(session) !== null; @@ -86,19 +90,17 @@ export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => { } if (!isSubtask) { topLevel.push({ - id: session.id, - title: session.title ?? '', - updated: session.time?.updated ?? session.time?.created ?? 0, + session, unread: needsAttention, project: projectLabelForDirectory(resolveGlobalSessionDirectory(session), projects), }); } } - topLevel.sort((a, b) => b.updated - a.updated); + topLevel.sort((a, b) => compareSessionsByLifecycleOrder(a.session, b.session, pinnedSessionIds, sessionOrderRanks)); const recentSessions = topLevel .slice(0, RECENT_LIMIT) - .map(({ id, title, unread, project }) => ({ id, title, unread, project })); + .map(({ session, unread, project }) => ({ id: session.id, title: session.title ?? '', unread, project })); return { runtimeKey: getRuntimeKey(), attentionCount, recentSessions }; }; diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index e66e52f5..e92a4063 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -16,6 +16,7 @@ import { useTerminalStore } from '@/stores/useTerminalStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { resetStreamingState } from '@/sync/streaming'; import { useGlobalSessionStatusStore } from '@/sync/global-session-status'; +import { resetSessionOrdering } from '@/sync/session-ordering'; import { syncDesktopSettings } from '@/lib/persistence'; // Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK @@ -54,6 +55,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD // previous instance — drop it so stale sessions can't linger after a switch. useGlobalSessionsStore.getState().resetForRuntimeSwitch(); useGlobalSessionStatusStore.setState({ statusById: new Map() }); + resetSessionOrdering(); usePermissionStore.getState().reset(); useFileSearchStore.getState().resetForRuntimeSwitch(); useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey); diff --git a/packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts b/packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts index 9d66285f..bb32dca9 100644 --- a/packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts +++ b/packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts @@ -2,6 +2,8 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; +import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering'; import { useSessionUIStore } from '@/sync/session-ui-store'; /** @@ -12,7 +14,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; * - Right edge → centre = next session (the older one) * * Navigation walks the same ranked list the rest of the mobile UI uses: top-level sessions - * (no subtasks) across all projects, newest-first by `time.updated`. The order is computed at + * (no subtasks) across all projects, lifecycle-ranked with timestamp fallback. The order is computed at * gesture time from the store (not subscribed) so it's always fresh and never re-attaches. * * Only `touchstart`/`touchend` are observed (both passive), so this never interferes with @@ -28,15 +30,16 @@ const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it hor const parentIdOf = (session: Session): string | null => (session as Session & { parentID?: string | null }).parentID ?? null; -const updatedAt = (session: Session): number => session.time?.updated ?? session.time?.created ?? 0; - -/** Top-level sessions across all projects, newest-first — the list the swipe walks. */ -const orderedTopLevelSessions = (): Session[] => - useGlobalSessionsStore +/** Top-level sessions across all projects in shared display order. */ +const orderedTopLevelSessions = (): Session[] => { + const pinnedSessionIds = useSessionPinnedStore.getState().ids; + const sessionOrderRanks = useSessionOrderingStore.getState().rankById; + return useGlobalSessionsStore .getState() .activeSessions.filter((session) => parentIdOf(session) === null) .slice() - .sort((a, b) => updatedAt(b) - updatedAt(a)); + .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); +}; /** * Switch to the session `step` positions away from the current one (clamped — no wrap). diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index f84dffc7..3d83b345 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -12,6 +12,8 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { Icon } from "@/components/icon/Icon"; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useNotificationStore } from '@/sync/notification-store'; +import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; +import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering'; import { useI18n } from '@/lib/i18n'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; @@ -77,6 +79,8 @@ function useSessionGrouping( sessionStatus: Record | undefined ) { const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); + const pinnedSessionIds = useSessionPinnedStore((state) => state.ids); + const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById); const parentChildMap = React.useMemo(() => { const map = new Map(); @@ -106,40 +110,23 @@ function useSessionGrouping( return !parentID || !sessionIds.has(parentID); }); - const running: SessionWithStatus[] = []; - const viewed: SessionWithStatus[] = []; - - topLevel.forEach((session) => { + const ordered = topLevel.map((session): SessionWithStatus => { const statusType = getStatusType(session.id); const runningChildrenCount = (parentChildMap.get(session.id) ?? []) .filter((child) => getStatusType(child.id) !== 'idle') .length; - const attention = (unseenCounts[session.id] ?? 0) > 0; - - const enriched: SessionWithStatus = { + return { ...session, _statusType: statusType, _runningChildrenCount: runningChildrenCount, }; - - if (statusType !== 'idle' || runningChildrenCount > 0 || attention) { - running.push(enriched); - } else { - viewed.push(enriched); - } }); - const sortByUpdated = (a: Session, b: Session) => { - const aTime = (a as unknown as { time?: { updated?: number } }).time?.updated ?? 0; - const bTime = (b as unknown as { time?: { updated?: number } }).time?.updated ?? 0; - return bTime - aTime; - }; - - running.sort(sortByUpdated); - viewed.sort(sortByUpdated); - - return [...running, ...viewed]; - }, [sessions, getStatusType, parentChildMap, unseenCounts]); + const compare = (a: Session, b: Session) => ( + compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks) + ); + return ordered.sort(compare); + }, [sessions, getStatusType, parentChildMap, pinnedSessionIds, sessionOrderRanks]); const totalRunning = processedSessions.reduce((sum, s) => { const selfRunning = s._statusType !== 'idle' ? 1 : 0; @@ -509,11 +496,12 @@ const MobileSessionStatusOpenPanel: React.FC = ({ return; } } - const mostRecent = [...sessions].sort((a, b) => { - const aTime = (a as { time?: { updated?: number } }).time?.updated ?? 0; - const bTime = (b as { time?: { updated?: number } }).time?.updated ?? 0; - return bTime - aTime; - })[0]; + const mostRecent = [...sessions].sort((a, b) => compareSessionsByLifecycleOrder( + a, + b, + useSessionPinnedStore.getState().ids, + useSessionOrderingStore.getState().rankById, + ))[0]; const directory = mostRecent ? sessionDirectory(mostRecent) : ''; openNewSessionDraft(directory ? { directoryOverride: directory } : undefined); }, [filterProjectId, projects, sessions, openNewSessionDraft, setOpen]); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index f3f7a48f..52ee121c 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -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 = ({ const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState(null); const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState(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>(() => { try { @@ -613,6 +622,7 @@ const SessionSidebarComponent: React.FC = ({ homeDirectory, worktreeMetadata, pinnedSessionIds, + sessionOrderRanks, gitBranches, isVSCode, }); @@ -632,20 +642,18 @@ const SessionSidebarComponent: React.FC = ({ 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 } | null>(null); @@ -654,14 +662,14 @@ const SessionSidebarComponent: React.FC = ({ 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(); - 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 = ({ 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(() => (
@@ -1070,6 +1078,7 @@ const SessionSidebarComponent: React.FC = ({ worktreeMetadata, availableWorktreesByProject, pinnedSessionIds, + sessionOrderRanks, foldersMap, collapsedFolderIds, gitBranches, @@ -1260,8 +1269,8 @@ const SessionSidebarComponent: React.FC = ({ } 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 = ({ /> diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 5f6fad53..0047bc9b 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -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. diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 1b4dac07..cc8f1627 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -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; diff --git a/packages/ui/src/components/session/sidebar/activitySections.ts b/packages/ui/src/components/session/sidebar/activitySections.ts index a01daa27..f655b0d6 100644 --- a/packages/ui/src/components/session/sidebar/activitySections.ts +++ b/packages/ui/src/components/session/sidebar/activitySections.ts @@ -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; }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts index 6085f21a..788a9684 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts @@ -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; pinnedSessionIds: Set; + sessionOrderRanks: ReadonlyMap; gitBranches: Map; 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(); @@ -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(); 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(); 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 { diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts index c742f7cc..ef49400f 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts @@ -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; }; diff --git a/packages/ui/src/components/session/sidebar/utils.tsx b/packages/ui/src/components/session/sidebar/utils.tsx index 2af0e05e..05e57157 100644 --- a/packages/ui/src/components/session/sidebar/utils.tsx +++ b/packages/ui/src/components/session/sidebar/utils.tsx @@ -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, -): 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(); sessions.forEach((session) => { diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 77bccafd..8785bc2d 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -18,6 +18,12 @@ import { import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; +import { + EMPTY_SESSION_ORDER_RANKS, + orderSessionsByLifecycleScopes, + useSessionOrderingStore, +} from '@/sync/session-ordering'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useGitAllBranches, useGitStore } from '@/stores/useGitStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; @@ -33,6 +39,8 @@ import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata'; + +const EMPTY_PINNED_SESSION_IDS = new Set(); import { getSettingsNavIcon } from '@/components/views/SettingsView'; import { Icon } from "@/components/icon/Icon"; import { McpIcon } from '@/components/icons/McpIcon'; @@ -90,6 +98,14 @@ export const CommandPalette: React.FC = () => { (state) => isCommandPaletteOpen ? state.activeSessions : EMPTY_SESSIONS, [isCommandPaletteOpen], )); + const pinnedSessionIds = useSessionPinnedStore(React.useCallback( + (state) => isCommandPaletteOpen ? state.ids : EMPTY_PINNED_SESSION_IDS, + [isCommandPaletteOpen], + )); + const sessionOrderRanks = useSessionOrderingStore(React.useCallback( + (state) => isCommandPaletteOpen ? state.rankById : EMPTY_SESSION_ORDER_RANKS, + [isCommandPaletteOpen], + )); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const activeProject = useProjectsStore((s) => s.getActiveProject()); const projects = useProjectsStore((s) => s.projects); @@ -299,12 +315,9 @@ export const CommandPalette: React.FC = () => { // --------------------------------------------------------------------------- // Sessions // --------------------------------------------------------------------------- - const sortedActiveSessions = React.useMemo(() => { - const getUpdated = (s: Session) => - (typeof s.time?.updated === 'number' ? s.time.updated : 0) || - (typeof s.time?.created === 'number' ? s.time.created : 0); - return [...activeSessions].sort((a, b) => getUpdated(b) - getUpdated(a)); - }, [activeSessions]); + const orderedActiveSessions = React.useMemo(() => { + return orderSessionsByLifecycleScopes(activeSessions, pinnedSessionIds, sessionOrderRanks); + }, [activeSessions, pinnedSessionIds, sessionOrderRanks]); const allBranches = useGitAllBranches(); const worktreeMetadata = useSessionUIStore((s) => s.worktreeMetadata); @@ -389,12 +402,12 @@ export const CommandPalette: React.FC = () => { }, [settingsEntries, liveTrimmed, hasQuery]); const scoredSessions = React.useMemo(() => { - if (!hasQuery) return sortedActiveSessions.slice(0, 5).map((item) => ({ item, score: 0 })); - return scoreByFuzzyQuery(sortedActiveSessions, liveTrimmed, (s) => s.title || '', { + if (!hasQuery) return orderedActiveSessions.slice(0, 5).map((item) => ({ item, score: 0 })); + return scoreByFuzzyQuery(orderedActiveSessions, liveTrimmed, (s) => s.title || '', { limit: 7, threshold: 0.2, }); - }, [sortedActiveSessions, liveTrimmed, hasQuery]); + }, [orderedActiveSessions, liveTrimmed, hasQuery]); const scoredFiles = React.useMemo(() => { if (!isCommandPaletteOpen) return []; diff --git a/packages/ui/src/hooks/useTraySync.ts b/packages/ui/src/hooks/useTraySync.ts index 15065f19..93268460 100644 --- a/packages/ui/src/hooks/useTraySync.ts +++ b/packages/ui/src/hooks/useTraySync.ts @@ -6,7 +6,9 @@ import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensi import { getSyncChildStores, getAllSyncSessions } from '@/sync/sync-refs'; import { opencodeClient } from '@/lib/opencode/client'; import { useGlobalSessionStatusStore, applyGlobalSessionStatusSnapshot } from '@/sync/global-session-status'; +import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering'; import { useNotificationStore } from '@/sync/notification-store'; +import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { respondToPermission } from '@/sync/session-actions'; import { useGlobalSessionsStore, @@ -119,8 +121,14 @@ const questionLabel = (request: QuestionRequest): string => { return first?.header || first?.question || 'Question'; }; -const updatedAt = (session: Session): number => - session.time?.updated ?? session.time?.created ?? 0; +const compareSessionOrder = (left: Session, right: Session): number => ( + compareSessionsByLifecycleOrder( + left, + right, + useSessionPinnedStore.getState().ids, + useSessionOrderingStore.getState().rankById, + ) +); const basenameOf = (p: string): string => { const norm = p.replace(/\\/g, '/').replace(/\/+$/, ''); @@ -300,7 +308,7 @@ const collectStatusPollDirectories = (): Map => { allSessions .filter((s) => s?.id && !s.parentID) .slice() - .sort((a, b) => updatedAt(b) - updatedAt(a)) + .sort(compareSessionOrder) .slice(0, MAX_SESSIONS) .forEach((session) => { const directory = resolveGlobalSessionDirectory(session); @@ -378,7 +386,7 @@ const buildSnapshot = (instanceName: string): TraySnapshot => { const sessions: TraySession[] = allSessions .filter((s) => s?.id && !s.parentID) // root rows; sub-session work rolls up .slice() - .sort((a, b) => updatedAt(b) - updatedAt(a)) // most recently updated first + .sort(compareSessionOrder) .slice(0, MAX_SESSIONS) .map((session) => { const family = [session.id, ...collectDescendants(session.id)]; @@ -524,6 +532,8 @@ export const useTraySync = (): void => { // Cross-project status map: fed live by the sync dispatcher from the global // event stream, and seeded/reconciled by the poll below. const unsubscribeGlobalStatus = useGlobalSessionStatusStore.subscribe(() => scheduleFlush()); + const unsubscribeSessionOrder = useSessionOrderingStore.subscribe(() => scheduleFlush()); + const unsubscribePinnedSessions = useSessionPinnedStore.subscribe(() => scheduleFlush()); // Make the tray self-sufficient: load the full cross-project list now // (independent of the sidebar) and refresh it periodically so sessions from @@ -573,6 +583,8 @@ export const useTraySync = (): void => { unsubscribeGit(); unsubscribeUI(); unsubscribeGlobalStatus(); + unsubscribeSessionOrder(); + unsubscribePinnedSessions(); unsubscribeQuota(); unsubscribeRegistry?.(); for (const unsub of storeUnsubs.values()) unsub(); diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 9d6db4bb..f28b7686 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -52,6 +52,8 @@ These stores coordinate persistent project/session metadata across multiple view `useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages. +User-visible session ordering is also not owned by the global cache array order. `sync/session-ordering.ts` combines lifecycle rank with timestamp fallbacks, and session surfaces must use that shared comparator instead of independently sorting global sessions by `time.updated`. + Global refresh rules: - Per-directory refresh is bounded to two requests across callers and prioritizes the current directory. diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 56bcfff8..34eb5889 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -44,6 +44,7 @@ So: | `ChildStoreManager` and child directory stores | Priority-scheduled directory bootstrap plus `session`, `message`, `part`, `permission`, `question`, etc. | One runtime and one store per directory | | `SessionMessageLoader` | Initial message loading, pagination, prefetch, retries, load state, and optimistic reconciliation | One runtime, directory, and session ID | | `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots | All known directories in the active runtime | +| `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime | | `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state | | `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists | | `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state | @@ -125,6 +126,8 @@ Current consumers: Cross-directory selectors subscribe to the narrow child-store field they aggregate. Session aggregation listens to `state.session`. Live busy/retry state is also maintained in `global-session-status.ts`, where each row subscribes to one session ID instead of scanning every child store. Events update the index incrementally; authoritative per-directory status snapshots seed it, clear sessions omitted as idle, and reconcile missed events. Unrelated streaming events such as `message.part.delta` must not trigger global session/status scans. +Session display order is independent from streaming-frequency `time.updated` publications. `session-ordering.ts` promotes a session exactly when its authoritative activity phase crosses `settled` (`idle`/`error`) and `active` (`busy`/`retry`) in either direction. Repeated busy/retry or idle/error events are no-ops. The first authoritative status snapshot establishes a baseline without synthetic promotions; later snapshots reconcile missed transitions. Root sessions compare lifecycle rank only with other roots, while child sessions compare lifecycle rank only with siblings sharing the same `parentID`, so child activity never moves its root conversation. Pins remain the first ordering bucket. The timestamp/creation fallback is frozen when a session first participates in ordering, so later metadata-only updates cannot reorder it; creation time and ID provide deterministic ties. Runtime switches clear all phases, baselines, and ranks. + Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call. VS Code does not run the server permission-auto-accept runtime. The extension host persists and broadcasts authoritative policy, while its foreground UI runtime resolves missing child-session lineage through the OpenCode API before deciding whether to suppress and answer a `permission.asked` event. Enabling the policy and reconnect/bootstrap both reconcile pending requests in the session directory, including requests inherited by child sessions. Unknown lineage and exhausted reply retries fail closed and leave the request available for manual action. A later `permission.replied` event invalidates any older deferred ask so the async policy check cannot resurrect a resolved request. With every OpenChamber webview closed or suspended no responder runs; this is an intentional VS Code limitation. Other runtimes remain fully server-owned. @@ -134,7 +137,7 @@ VS Code does not run the server permission-auto-accept runtime. The extension ho `useGlobalSessionsStore` is kept correct by: 1. shared global fetch/reconciliation via `loadSessions()` / `refreshGlobalSessions()` -2. session create/update/delete events; recency-only updates for existing sessions are retained latest-per-session and committed once on `session.idle`/`session.error`, while structural updates and create/delete remain immediate and runtime switching discards pending updates +2. session create/update/delete events; recency-only updates for existing sessions are retained latest-per-session and committed once on `session.idle`/`session.error`, while structural updates and create/delete remain immediate and runtime switching discards pending updates. Display ordering reacts separately to active/settled lifecycle transitions, not to these recency publications 3. direct mutation from session actions after successful SDK calls: - create - title update diff --git a/packages/ui/src/sync/global-session-status.test.ts b/packages/ui/src/sync/global-session-status.test.ts index e21f2b9e..923b50c2 100644 --- a/packages/ui/src/sync/global-session-status.test.ts +++ b/packages/ui/src/sync/global-session-status.test.ts @@ -5,9 +5,11 @@ import { applyGlobalSessionStatusSnapshot, useGlobalSessionStatusStore, } from "./global-session-status" +import { resetSessionOrdering, useSessionOrderingStore } from "./session-ordering" beforeEach(() => { useGlobalSessionStatusStore.setState({ statusById: new Map() }) + resetSessionOrdering() }) describe("global session status index", () => { @@ -27,6 +29,33 @@ describe("global session status index", () => { }) }) + test("promotes on active and settled lifecycle edges only", () => { + applyGlobalSessionStatusEvent("/repo", { + type: "session.status", + properties: { sessionID: "session-a", status: { type: "busy" } }, + } as Event) + const busyRank = useSessionOrderingStore.getState().rankById.get("session-a") + + applyGlobalSessionStatusEvent("/repo", { + type: "session.status", + properties: { sessionID: "session-a", status: { type: "retry", attempt: 1, message: "wait", next: 1 } }, + } as Event) + expect(useSessionOrderingStore.getState().rankById.get("session-a")).toBe(busyRank) + + applyGlobalSessionStatusEvent("/repo", { + type: "session.idle", + properties: { sessionID: "session-a" }, + } as Event) + const idleRank = useSessionOrderingStore.getState().rankById.get("session-a") + expect(idleRank).toBeGreaterThan(busyRank ?? 0) + + applyGlobalSessionStatusEvent("/repo", { + type: "session.error", + properties: { sessionID: "session-a" }, + } as Event) + expect(useSessionOrderingStore.getState().rankById.get("session-a")).toBe(idleRank) + }) + test("authoritative snapshots clear absent active entries for their directory", () => { applyGlobalSessionStatusSnapshot("/repo", { "session-a": { type: "busy" } }, ["session-a"]) expect(useGlobalSessionStatusStore.getState().statusById.get("session-a")?.status.type).toBe("busy") diff --git a/packages/ui/src/sync/global-session-status.ts b/packages/ui/src/sync/global-session-status.ts index 1321614a..e2601bcf 100644 --- a/packages/ui/src/sync/global-session-status.ts +++ b/packages/ui/src/sync/global-session-status.ts @@ -1,6 +1,11 @@ import { create } from 'zustand'; import type { Event, SessionStatus } from '@opencode-ai/sdk/v2/client'; import { normalizeProjectPath } from '@/lib/projectResolution'; +import { + observeSessionActivityEvent, + reconcileSessionActivitySnapshot, + removeSessionOrdering, +} from './session-ordering'; // Shared live busy/retry index for every directory. Global events update it // incrementally and authoritative directory snapshots reconcile it, so each @@ -22,8 +27,15 @@ export const useGlobalSessionStatusStore = create(() = statusById: new Map(), })); -const normalizeStatusType = (type: unknown): ActiveStatusType | 'idle' => - type === 'busy' ? 'busy' : type === 'retry' ? 'retry' : 'idle'; +const normalizeStatusType = (type: unknown): ActiveStatusType | 'idle' => { + if (type === 'busy') return 'busy'; + if (type === 'retry') return 'retry'; + return 'idle'; +}; + +const statusesEqual = (left: SessionStatus, right: SessionStatus): boolean => ( + left.type === right.type && JSON.stringify(left) === JSON.stringify(right) +); // Both write paths normalize the directory key, so a polled snapshot can // authoritatively replace entries written by events (and vice versa) even when @@ -40,8 +52,7 @@ const setStatus = (sessionId: string, directory: string, status: SessionStatus | next.delete(sessionId); return { statusById: next }; } - if (current && current.status.type === status.type && current.directory === directory - && JSON.stringify(current.status) === JSON.stringify(status)) return state; + if (current && current.directory === directory && statusesEqual(current.status, status)) return state; const next = new Map(state.statusById); next.set(sessionId, { status, directory }); return { statusById: next }; @@ -62,6 +73,7 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event) normalizeDirectory(directory), type === 'idle' ? { type: 'idle' } : { ...(props.status ?? {}), type } as SessionStatus, ); + observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active'); return; } case 'session.idle': @@ -69,9 +81,16 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event) const props = payload.properties as { sessionID?: string } | undefined; if (typeof props?.sessionID === 'string' && props.sessionID) { setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' }); + observeSessionActivityEvent(props.sessionID, 'settled'); } return; } + case 'session.deleted': { + const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined; + const sessionId = props?.sessionID ?? props?.info?.id; + if (sessionId) removeSessionOrdering(sessionId); + return; + } default: return; } @@ -89,6 +108,10 @@ export const applyGlobalSessionStatusSnapshot = ( ): void => { const directory = normalizeDirectory(rawDirectory); const known = new Set(knownSessionIds ?? []); + const activeSessionIds = Object.entries(raw) + .filter(([, status]) => normalizeStatusType(status?.type) !== 'idle') + .map(([sessionId]) => sessionId); + reconcileSessionActivitySnapshot(activeSessionIds, known); useGlobalSessionStatusStore.setState((state) => { let changed = false; const next = new Map(state.statusById); @@ -111,8 +134,7 @@ export const applyGlobalSessionStatusSnapshot = ( continue; } const normalizedStatus = { ...status, type } as SessionStatus; - if (!current || current.status.type !== type || current.directory !== directory - || JSON.stringify(current.status) !== JSON.stringify(normalizedStatus)) { + if (!current || current.directory !== directory || !statusesEqual(current.status, normalizedStatus)) { next.set(sessionId, { status: normalizedStatus, directory }); changed = true; } diff --git a/packages/ui/src/sync/session-ordering.test.ts b/packages/ui/src/sync/session-ordering.test.ts new file mode 100644 index 00000000..ee4608f6 --- /dev/null +++ b/packages/ui/src/sync/session-ordering.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { + compareSessionsByLifecycleOrder, + observeSessionActivityEvent, + orderSessionsByLifecycleScopes, + reconcileSessionActivitySnapshot, + removeSessionOrdering, + resetSessionOrdering, + useSessionOrderingStore, +} from './session-ordering'; + +const session = ( + id: string, + updated: number, + parentID?: string, +): Session => ({ + id, + parentID, + time: { created: updated - 1, updated }, +} as Session); + +beforeEach(() => resetSessionOrdering()); + +describe('session lifecycle ordering', () => { + test('promotes only meaningful event transitions', () => { + observeSessionActivityEvent('session-a', 'settled'); + expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(false); + + observeSessionActivityEvent('session-a', 'active'); + const activeRank = useSessionOrderingStore.getState().rankById.get('session-a'); + expect(typeof activeRank).toBe('number'); + + observeSessionActivityEvent('session-a', 'active'); + expect(useSessionOrderingStore.getState().rankById.get('session-a')).toBe(activeRank); + + observeSessionActivityEvent('session-a', 'settled'); + expect(useSessionOrderingStore.getState().rankById.get('session-a')).toBeGreaterThan(activeRank ?? 0); + }); + + test('treats an active event without a snapshot baseline as a real transition', () => { + observeSessionActivityEvent('session-a', 'active'); + + expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(true); + }); + + test('seeds the first authoritative snapshot without synthetic promotions', () => { + reconcileSessionActivitySnapshot(['session-a'], ['session-a', 'session-b']); + expect(useSessionOrderingStore.getState().rankById.size).toBe(0); + + reconcileSessionActivitySnapshot([], ['session-a', 'session-b']); + expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(true); + expect(useSessionOrderingStore.getState().rankById.has('session-b')).toBe(false); + }); + + test('uses lifecycle rank only within the same parent scope', () => { + const rootOlder = session('root-older', 10); + const rootNewer = session('root-newer', 20); + const childOlder = session('child-older', 10, 'root-older'); + const childNewer = session('child-newer', 20, 'root-older'); + const otherParentChild = session('other-parent-child', 20, 'root-newer'); + const rankById = new Map([ + ['child-older', 100], + ['root-older', 90], + ]); + + expect(compareSessionsByLifecycleOrder(rootOlder, rootNewer, new Set(), rankById)).toBeLessThan(0); + expect(compareSessionsByLifecycleOrder(childOlder, childNewer, new Set(), rankById)).toBeLessThan(0); + expect(compareSessionsByLifecycleOrder(childOlder, otherParentChild, new Set(), rankById)).toBeGreaterThan(0); + expect(compareSessionsByLifecycleOrder(childOlder, rootNewer, new Set(), rankById)).toBeGreaterThan(0); + }); + + test('freezes timestamp fallback until a lifecycle transition', () => { + const older = session('older', 10); + const newer = session('newer', 20); + expect(compareSessionsByLifecycleOrder(older, newer, new Set(), new Map())).toBeGreaterThan(0); + + const metadataOnlyUpdate = session('older', 30); + expect(compareSessionsByLifecycleOrder(metadataOnlyUpdate, newer, new Set(), new Map())).toBeGreaterThan(0); + + expect(compareSessionsByLifecycleOrder( + metadataOnlyUpdate, + newer, + new Set(), + new Map([['older', 40]]), + )).toBeLessThan(0); + }); + + test('clears lifecycle state when a session is deleted', () => { + observeSessionActivityEvent('session-a', 'active'); + removeSessionOrdering('session-a'); + expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(false); + + observeSessionActivityEvent('session-a', 'settled'); + expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(false); + }); + + test('sorts each forest scope before flattening parent-first', () => { + const rootOlder = session('root-older', 10); + const rootNewer = session('root-newer', 20); + const childOlder = session('child-older', 5, 'root-older'); + const childNewer = session('child-newer', 6, 'root-older'); + + const ordered = orderSessionsByLifecycleScopes( + [rootNewer, childOlder, rootOlder, childNewer], + new Set(), + new Map([ + ['root-older', 100], + ['child-older', 90], + ]), + ); + + expect(ordered.map((item) => item.id)).toEqual([ + 'root-older', + 'child-older', + 'child-newer', + 'root-newer', + ]); + }); + + test('does not promote a root when only its child has lifecycle activity', () => { + const rootOlder = session('root-older', 10); + const rootNewer = session('root-newer', 20); + const activeChild = session('active-child', 5, 'root-older'); + + const ordered = orderSessionsByLifecycleScopes( + [rootOlder, activeChild, rootNewer], + new Set(), + new Map([['active-child', 100]]), + ); + + expect(ordered.map((item) => item.id)).toEqual([ + 'root-newer', + 'root-older', + 'active-child', + ]); + }); +}); diff --git a/packages/ui/src/sync/session-ordering.ts b/packages/ui/src/sync/session-ordering.ts new file mode 100644 index 00000000..cddc7c25 --- /dev/null +++ b/packages/ui/src/sync/session-ordering.ts @@ -0,0 +1,203 @@ +import { create } from 'zustand'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { isSessionPinned } from '@/stores/useSessionPinnedStore'; +import { normalizePath } from '@/lib/pathNormalization'; + +type SessionActivityPhase = 'active' | 'settled'; + +type SessionOrderingState = { + rankById: Map; +}; + +export const EMPTY_SESSION_ORDER_RANKS: ReadonlyMap = new Map(); + +const phaseById = new Map(); +const baselineRankById = new Map(); +let lastRank = 0; + +export const useSessionOrderingStore = create(() => ({ + rankById: new Map(), +})); + +const nextRank = (): number => { + lastRank = Math.max(lastRank + 1, Date.now()); + return lastRank; +}; + +const promoteSessions = (sessionIds: Iterable, useSharedRank = false): void => { + const ids = [...sessionIds]; + if (ids.length === 0) return; + + useSessionOrderingStore.setState((state) => { + const rankById = new Map(state.rankById); + const sharedRank = useSharedRank ? nextRank() : null; + for (const sessionId of ids) { + rankById.set(sessionId, sharedRank ?? nextRank()); + } + return { rankById }; + }); +}; + +export const observeSessionActivityEvent = ( + sessionId: string, + phase: SessionActivityPhase, +): void => { + const previous = phaseById.get(sessionId); + phaseById.set(sessionId, phase); + + if (previous === phase) return; + if (previous === undefined && phase === 'settled') return; + promoteSessions([sessionId]); +}; + +export const reconcileSessionActivitySnapshot = ( + activeSessionIds: Iterable, + knownSessionIds: Iterable, +): void => { + const active = new Set(activeSessionIds); + const observed = new Set([...knownSessionIds, ...active]); + const promoted: string[] = []; + + for (const sessionId of observed) { + const phase: SessionActivityPhase = active.has(sessionId) ? 'active' : 'settled'; + const previous = phaseById.get(sessionId); + phaseById.set(sessionId, phase); + if (previous !== undefined && previous !== phase) promoted.push(sessionId); + } + + // A snapshot cannot recover the order of missed transitions. Give the batch + // one rank and let authoritative timestamps break ties deterministically. + promoteSessions(promoted, true); +}; + +export const removeSessionOrdering = (sessionId: string): void => { + phaseById.delete(sessionId); + baselineRankById.delete(sessionId); + useSessionOrderingStore.setState((state) => { + if (!state.rankById.has(sessionId)) return state; + const rankById = new Map(state.rankById); + rankById.delete(sessionId); + return { rankById }; + }); +}; + +export const resetSessionOrdering = (): void => { + phaseById.clear(); + baselineRankById.clear(); + lastRank = 0; + useSessionOrderingStore.setState({ rankById: new Map() }); +}; + +const finiteTime = (value: unknown): number => ( + typeof value === 'number' && Number.isFinite(value) ? value : 0 +); + +const updatedAt = (session: Session): number => ( + finiteTime(session.time?.updated) || finiteTime(session.time?.created) +); + +const createdAt = (session: Session): number => finiteTime(session.time?.created); + +const parentIdOf = (session: Session): string | null => ( + (session as Session & { parentID?: string | null }).parentID ?? null +); + +const sessionDirectory = (session: Session): string | null => { + const record = session as Session & { + directory?: string | null; + project?: { worktree?: string | null } | null; + }; + return normalizePath(record.directory ?? null) ?? normalizePath(record.project?.worktree ?? null); +}; + +const baselineRank = (session: Session, pinned: boolean): number => { + const existing = baselineRankById.get(session.id); + const key = pinned ? 'created' : 'updated'; + const existingRank = existing?.[key]; + if (existingRank !== undefined) return existingRank; + const rank = pinned ? createdAt(session) : updatedAt(session); + baselineRankById.set(session.id, { ...existing, [key]: rank }); + return rank; +}; + +export const getSessionLifecycleOrderValue = ( + session: Session, + rankById: ReadonlyMap, + pinned = false, +): number => rankById.get(session.id) ?? baselineRank(session, pinned); + +export const compareSessionsByLifecycleOrder = ( + left: Session, + right: Session, + pinnedSessionIds: Set, + rankById: ReadonlyMap, +): number => { + const leftPinned = isSessionPinned(pinnedSessionIds, sessionDirectory(left), left.id); + const rightPinned = isSessionPinned(pinnedSessionIds, sessionDirectory(right), right.id); + if (leftPinned !== rightPinned) return leftPinned ? -1 : 1; + + const leftFallback = baselineRank(left, leftPinned); + const rightFallback = baselineRank(right, rightPinned); + if (parentIdOf(left) === parentIdOf(right)) { + const rankDelta = getSessionLifecycleOrderValue(right, rankById, rightPinned) + - getSessionLifecycleOrderValue(left, rankById, leftPinned); + if (rankDelta !== 0) return rankDelta; + } + + const baselineDelta = rightFallback - leftFallback; + if (baselineDelta !== 0) return baselineDelta; + const createdDelta = baselineRank(right, true) - baselineRank(left, true); + if (createdDelta !== 0) return createdDelta; + return left.id.localeCompare(right.id); +}; + +export const orderSessionsByLifecycleScopes = ( + sessions: Session[], + pinnedSessionIds: Set, + rankById: ReadonlyMap, +): Session[] => { + const sessionIds = new Set(sessions.map((session) => session.id)); + const roots: Session[] = []; + const childrenByParent = new Map(); + + for (const session of sessions) { + const parentId = parentIdOf(session); + if (!parentId || !sessionIds.has(parentId)) { + roots.push(session); + continue; + } + + const siblings = childrenByParent.get(parentId); + if (siblings) { + siblings.push(session); + } else { + childrenByParent.set(parentId, [session]); + } + } + + const compare = (left: Session, right: Session) => ( + compareSessionsByLifecycleOrder(left, right, pinnedSessionIds, rankById) + ); + roots.sort(compare); + for (const siblings of childrenByParent.values()) { + siblings.sort(compare); + } + + const ordered: Session[] = []; + const visited = new Set(); + const append = (session: Session): void => { + if (visited.has(session.id)) return; + visited.add(session.id); + ordered.push(session); + for (const child of childrenByParent.get(session.id) ?? []) { + append(child); + } + }; + for (const root of roots) { + append(root); + } + for (const session of sessions) { + append(session); + } + return ordered; +};