From df7fcd665cc565dd614c2aed10d3dcc8b2bf4633 Mon Sep 17 00:00:00 2001 From: czc <147292653+czc6666@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:55:30 +0800 Subject: [PATCH] fix(desktop): preserve manual pinned session order --- .../sidebar/sessions-section-order.test.ts | 38 +++++++++++++++++++ .../chat/sidebar/sessions-section-order.ts | 8 ++++ .../src/app/chat/sidebar/sessions-section.tsx | 3 +- .../src/store/layout-pinned-order.test.ts | 17 +++++++++ apps/desktop/src/store/layout.ts | 16 ++++++-- 5 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/sessions-section-order.test.ts create mode 100644 apps/desktop/src/app/chat/sidebar/sessions-section-order.ts create mode 100644 apps/desktop/src/store/layout-pinned-order.test.ts diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section-order.test.ts b/apps/desktop/src/app/chat/sidebar/sessions-section-order.test.ts new file mode 100644 index 000000000000..57dc82157e24 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/sessions-section-order.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import { sessionEntriesForSection } from './sessions-section-order' + +const session = (id: string, lastActive: number): SessionInfo => + ({ + ended_at: null, + id, + input_tokens: 0, + is_active: false, + last_active: lastActive, + message_count: 1, + model: null, + output_tokens: 0, + preview: null, + source: 'desktop', + started_at: lastActive, + title: id, + tool_call_count: 0 + }) as SessionInfo + +describe('sessionEntriesForSection', () => { + it('preserves the persisted user order for pinned sessions', () => { + const older = session('older', 1) + const newer = session('newer', 2) + + expect(sessionEntriesForSection([older, newer], true).map(entry => entry.session.id)).toEqual(['older', 'newer']) + }) + + it('keeps the activity ordering used by regular session lists', () => { + const older = session('older', 1) + const newer = session('newer', 2) + + expect(sessionEntriesForSection([older, newer], false).map(entry => entry.session.id)).toEqual(['newer', 'older']) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section-order.ts b/apps/desktop/src/app/chat/sidebar/sessions-section-order.ts new file mode 100644 index 000000000000..616dd6a72011 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/sessions-section-order.ts @@ -0,0 +1,8 @@ +import type { SessionInfo } from '@/hermes' +import { flattenSessionsWithBranches, type SidebarSessionEntry } from '@/lib/session-branch-tree' + +export function sessionEntriesForSection(sessions: readonly SessionInfo[], pinned: boolean): SidebarSessionEntry[] { + // Pins are already in the user's persisted order. The branch flattener sorts + // top-level groups by activity, which would undo a successful drag reorder. + return pinned ? sessions.map(session => ({ session })) : flattenSessionsWithBranches(sessions) +} diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index ffe729eb51e4..dd7e2dd51083 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -23,6 +23,7 @@ import { import { ReorderableList, useSortableBindings } from './reorderable-list' import { SidebarSessionSkeletons } from './section-states' import { SidebarSessionRow } from './session-row' +import { sessionEntriesForSection } from './sessions-section-order' import { VirtualSessionList } from './virtual-session-list' export const VIRTUALIZE_THRESHOLD = 25 @@ -189,7 +190,7 @@ 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]) + const displayEntries = useMemo(() => sessionEntriesForSection(sessions, pinned), [pinned, sessions]) const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { const rowProps = { diff --git a/apps/desktop/src/store/layout-pinned-order.test.ts b/apps/desktop/src/store/layout-pinned-order.test.ts new file mode 100644 index 000000000000..cd38b2e3a7a5 --- /dev/null +++ b/apps/desktop/src/store/layout-pinned-order.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { reorderedPinnedSessionIds } from './layout' + +describe('reorderedPinnedSessionIds', () => { + it('persists an exact visible reorder', () => { + expect(reorderedPinnedSessionIds(['a', 'b', 'c'], ['c', 'a', 'b'])).toEqual(['c', 'a', 'b']) + }) + + it('reorders visible pins while preserving stale or unresolved durable pins', () => { + expect(reorderedPinnedSessionIds(['a', 'stale', 'b', 'c'], ['c', 'a', 'b'])).toEqual(['c', 'a', 'b', 'stale']) + }) + + it('ignores duplicate and unpinned ids from the rendered order', () => { + expect(reorderedPinnedSessionIds(['a', 'b'], ['b', 'b', 'not-pinned', 'a'])).toEqual(['b', 'a']) + }) +}) diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index 25cbab5c7b60..e012428eaf02 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -322,12 +322,22 @@ export function unpinSession(sessionId: string) { // Replace the whole pinned order at once (drag-reorder hands back the new order // rather than a single move). Keep only ids that are actually pinned so a stale // row can't smuggle an unpinned id into the store. +export function reorderedPinnedSessionIds(prev: string[], ids: string[]): string[] { + const pinned = new Set(prev) + const seen = new Set() + const visibleOrder = ids.filter(id => pinned.has(id) && !seen.has(id) && Boolean(seen.add(id))) + + // The rendered list can contain fewer rows than the durable pin store after + // compression/lineage dedup or when an old pin is temporarily unresolved. + // Keep those hidden ids, but do not let them veto reordering the visible rows. + return [...visibleOrder, ...prev.filter(id => !seen.has(id))] +} + export function setPinnedSessionOrder(ids: string[]) { const prev = $pinnedSessionIds.get() - const pinned = new Set(prev) - const next = ids.filter(id => pinned.has(id)) + const next = reorderedPinnedSessionIds(prev, ids) - if (next.length === prev.length && !arraysEqual(prev, next)) { + if (!arraysEqual(prev, next)) { $pinnedSessionIds.set(next) } }