From 9c28771cf3121b2e03fe752737ffe638a0173c6b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 01:38:21 -0500 Subject: [PATCH] fix(desktop): keep pinned sidebar rows in user order flattenSessionsWithBranches always re-sorted roots by last_active, so a turn finishing floated background tasks over the hand-picked Pinned list even though $pinnedSessionIds already stored drag order. preserveOrder skips that sort for pins (and other non-date-grouped manual lists); default recents stay recency-sorted for truthful date buckets. --- .../src/app/chat/sidebar/sessions-section.tsx | 10 ++++++- .../src/lib/session-branch-tree.test.ts | 30 +++++++++++++++++++ apps/desktop/src/lib/session-branch-tree.ts | 26 +++++++++++++--- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index d3a65e1d787b..4d7578bde94e 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -205,7 +205,15 @@ export function SidebarSessionsSection({ // The flat recents/pinned list is the only place sessions reorder by hand; // grouped/tree views always sort by creation date and never drag. const sessionsDraggable = sortable && !!onReorderSessions - const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions]) + // Pinned and manual-drag lists pass sessions already in the caller's order. + // Default recents stay dateGrouped and still re-sort roots by group recency + // so partition buckets stay truthful — but NEVER for pins, where a turn + // finishing was floating background tasks over the user's fixed ranking. + const preserveInputOrder = pinned || (sessionsDraggable && !dateGrouped) + const displayEntries = useMemo( + () => flattenSessionsWithBranches(sessions, { preserveOrder: preserveInputOrder }), + [sessions, preserveInputOrder] + ) const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { const rowProps = { diff --git a/apps/desktop/src/lib/session-branch-tree.test.ts b/apps/desktop/src/lib/session-branch-tree.test.ts index 133fa324cb40..acdc0a3abf93 100644 --- a/apps/desktop/src/lib/session-branch-tree.test.ts +++ b/apps/desktop/src/lib/session-branch-tree.test.ts @@ -50,4 +50,34 @@ describe('flattenSessionsWithBranches', () => { expect(flattenSessionsWithBranches([branch])).toEqual([{ session: branch }]) }) + + it('re-sorts roots by group recency by default (pinned-style jumps without preserveOrder)', () => { + // Stale important chat first in the caller's array; a recently-active + // background task second. Default path must lift the fresher root — that + // is what was scrambling the Pinned section before preserveOrder. + const important = session('important', { last_active: 10 }) + const background = session('background', { last_active: 99 }) + + expect(flattenSessionsWithBranches([important, background]).map(e => e.session.id)).toEqual([ + 'background', + 'important' + ]) + }) + + it("preserveOrder keeps the caller's root order even when activity is newer lower down", () => { + const important = session('important', { last_active: 10 }) + const background = session('background', { last_active: 99 }) + const branch = session('branch', { last_active: 50, parent_session_id: 'important' }) + + expect( + flattenSessionsWithBranches([important, background, branch], { preserveOrder: true }).map(e => ({ + id: e.session.id, + stem: e.branchStem + })) + ).toEqual([ + { id: 'important', stem: undefined }, + { id: 'branch', stem: '└─ ' }, + { id: 'background', stem: undefined } + ]) + }) }) diff --git a/apps/desktop/src/lib/session-branch-tree.ts b/apps/desktop/src/lib/session-branch-tree.ts index f99360d1c5ed..c415d873e2f1 100644 --- a/apps/desktop/src/lib/session-branch-tree.ts +++ b/apps/desktop/src/lib/session-branch-tree.ts @@ -5,10 +5,23 @@ export interface SidebarSessionEntry { session: SessionInfo } +export interface FlattenSessionsOptions { + /** + * Keep the input root order instead of re-sorting by group recency. + * Use for hand-ordered surfaces (pinned ids, manual recents drag) so a + * turn completing can't float a row. Branch children still nest under + * their parent; sibling branches stay ordered by their own recency. + */ + preserveOrder?: boolean +} + const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0 /** Flat list with branch/fork sessions nested visually under their parent. */ -export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): SidebarSessionEntry[] { +export function flattenSessionsWithBranches( + sessions: readonly SessionInfo[], + options: FlattenSessionsOptions = {} +): SidebarSessionEntry[] { if (sessions.length < 2) { return sessions.map(session => ({ session })) } @@ -53,6 +66,7 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S // A group sorts by its freshest member, so activity on any branch lifts the // whole parent→branches cluster together instead of stranding the parent at // its own stale timestamp. Memoized — each subtree is folded at most once. + // Skipped when preserveOrder is set: the caller already chose positions. const groupRecencyMemo = new Map() const groupRecency = (session: SessionInfo): number => { @@ -92,11 +106,15 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S children?.forEach((child, index) => emit(child, index === children.length - 1 ? '└─ ' : '├─ ')) } - sessions + const roots = sessions .filter(session => !nestedIds.has(session.id)) .map((session, index) => ({ index, session })) - .sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index) - .forEach(({ session }) => emit(session)) + + if (!options.preserveOrder) { + roots.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index) + } + + roots.forEach(({ session }) => emit(session)) for (const session of sessions) { if (!seen.has(session.id)) {