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
+25 -7
View File
@@ -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<string>();
type ProjectMeta = {
id: string;
label: string;
@@ -519,6 +527,14 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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<MobileSessionsSheetProps> = ({ 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<MobileSessionsSheetProps> = ({ open,
}
return nodes;
}, [activeProjectId, projectsMeta, sessions]);
}, [activeProjectId, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]);
const normalizedDirectory = normalizePath(currentDirectory);
@@ -923,14 +939,16 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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<ProjectMeta & { sessionCount: number }>;
+9 -7
View File
@@ -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 };
};
@@ -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);
@@ -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).