From 8fdb92f44936c0137bbdcade7d8b5d280347e1dd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 03:13:04 -0500 Subject: [PATCH] fix(desktop): hoist the sidebar's sort key out of the flat list The sort key was applied where the flat recents list is assembled, so it did nothing at all once rows moved into groups: picking "cost" while grouped by project or profile left every lane in the order the backend sent it. Rank in a store instead, above any one view, so a grouped surface can order the rows it owns by the same key. --- .../src/app/chat/sidebar/order.test.ts | 18 +++++ apps/desktop/src/app/chat/sidebar/order.ts | 9 +++ apps/desktop/src/store/sidebar-sort.test.ts | 65 +++++++++++++++++++ apps/desktop/src/store/sidebar-sort.ts | 62 ++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 apps/desktop/src/store/sidebar-sort.test.ts create mode 100644 apps/desktop/src/store/sidebar-sort.ts diff --git a/apps/desktop/src/app/chat/sidebar/order.test.ts b/apps/desktop/src/app/chat/sidebar/order.test.ts index 6a12d8e086892..37a4b36df96de 100644 --- a/apps/desktop/src/app/chat/sidebar/order.test.ts +++ b/apps/desktop/src/app/chat/sidebar/order.test.ts @@ -6,6 +6,7 @@ import type { SessionInfo } from '@/types/hermes' import { orderByIds, orderRowsWithinGroups, + rankSessions, reconcileOrderIds, reorderableRowIds, resolveManualSessionOrderIds, @@ -64,6 +65,23 @@ describe('orderByIds', () => { }) }) +describe('rankSessions', () => { + const sessions = [{ id: 'newest' }, { id: 'middle' }, { id: 'oldest' }] + + it('leaves the lane alone when the sidebar is on its default sort', () => { + expect(rankSessions(sessions)).toBe(sessions) + expect(rankSessions(sessions, [])).toBe(sessions) + }) + + it('applies the active sort key to a lane the flat list never renders', () => { + expect(rankSessions(sessions, ['oldest', 'newest', 'middle']).map(s => s.id)).toEqual([ + 'oldest', + 'newest', + 'middle' + ]) + }) +}) + describe('reconcileOrderIds', () => { it('returns empty for no current ids', () => { expect(reconcileOrderIds([], ['a'])).toEqual([]) diff --git a/apps/desktop/src/app/chat/sidebar/order.ts b/apps/desktop/src/app/chat/sidebar/order.ts index 9998243722d43..c65a53dc0308b 100644 --- a/apps/desktop/src/app/chat/sidebar/order.ts +++ b/apps/desktop/src/app/chat/sidebar/order.ts @@ -103,6 +103,15 @@ export function orderByIds(items: T[], getId: (item: T) => string, orderIds: return [...newer, ...ordered, ...older] } +/** + * Apply the active sort key (as an id order) to a set of session rows, leaving + * them in the order they came in when nothing is ranked. Grouped views call + * this on their own lane so a sort key reaches rows the flat list never renders. + */ +export function rankSessions(sessions: T[], rankIds?: string[]): T[] { + return rankIds?.length ? orderByIds(sessions, session => session.id, rankIds) : sessions +} + /** Reconcile a persisted order against the live id set. */ export function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { if (!currentIds.length) { diff --git a/apps/desktop/src/store/sidebar-sort.test.ts b/apps/desktop/src/store/sidebar-sort.test.ts new file mode 100644 index 0000000000000..1703c5b8f213b --- /dev/null +++ b/apps/desktop/src/store/sidebar-sort.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/hermes' + +import { resetSidebarView, setSidebarOrdering } from './layout' +import { $sessions } from './session' +import { $sidebarSessionRankIds } from './sidebar-sort' + +const session = (id: string, fields: Partial) => + ({ id, input_tokens: 0, output_tokens: 0, started_at: 0, ...fields }) as SessionInfo + +beforeEach(() => { + resetSidebarView() + $sessions.set([]) +}) + +describe('$sidebarSessionRankIds', () => { + it('ranks the priciest session first', () => { + $sessions.set([ + session('cheap', { actual_cost_usd: 0.01 }), + session('dear', { actual_cost_usd: 2 }), + session('estimated', { estimated_cost_usd: 0.5 }) + ]) + setSidebarOrdering('cost') + + expect($sidebarSessionRankIds.get()).toEqual(['dear', 'estimated', 'cheap']) + }) + + it('ranks by total tokens, both halves counted', () => { + $sessions.set([ + session('small', { input_tokens: 10, output_tokens: 10 }), + session('big', { input_tokens: 1, output_tokens: 500 }) + ]) + setSidebarOrdering('tokens') + + expect($sidebarSessionRankIds.get()).toEqual(['big', 'small']) + }) + + it('ranks by creation, newest first — the sidebar orders by recency elsewhere', () => { + $sessions.set([session('older', { started_at: 1 }), session('newer', { started_at: 9 })]) + setSidebarOrdering('created') + + expect($sidebarSessionRankIds.get()).toEqual(['newer', 'older']) + }) + + it('leaves the default view unranked, and hands back the same array each time', () => { + $sessions.set([session('a', { actual_cost_usd: 1 }), session('b', { actual_cost_usd: 2 })]) + + const first = $sidebarSessionRankIds.get() + + $sessions.set([session('c', { actual_cost_usd: 3 })]) + + expect(first).toEqual([]) + // Reference-stable, so the default sidebar never repaints on a rank it isn't using. + expect($sidebarSessionRankIds.get()).toBe(first) + }) + + it('drops the ranking when a hand-dragged order takes over', () => { + $sessions.set([session('a', { actual_cost_usd: 1 }), session('b', { actual_cost_usd: 2 })]) + setSidebarOrdering('cost') + setSidebarOrdering('manual') + + expect($sidebarSessionRankIds.get()).toEqual([]) + }) +}) diff --git a/apps/desktop/src/store/sidebar-sort.ts b/apps/desktop/src/store/sidebar-sort.ts new file mode 100644 index 0000000000000..9b7312603ad4b --- /dev/null +++ b/apps/desktop/src/store/sidebar-sort.ts @@ -0,0 +1,62 @@ +import { computed, type ReadableAtom } from 'nanostores' + +import type { SessionInfo } from '@/hermes' + +import { $sidebarOrdering, type SidebarOrdering } from './layout' +import { $sessions } from './session' +import { $sessionDotStateById, type SessionDotState, sessionStatusRank } from './session-dot-state' +import { sessionCostUsd } from './sidebar-archive' + +// Same array on every recompute, so the default (unranked) sidebar never churns +// its subscribers. +const UNRANKED: string[] = [] + +function rankBy( + ordering: SidebarOrdering, + dotStates: Record +): null | ((session: SessionInfo) => number) { + switch (ordering) { + case 'cost': + return session => -sessionCostUsd(session) + + case 'created': + return session => -session.started_at + + case 'status': + return session => sessionStatusRank(dotStates[session.id]) + + case 'tokens': + return session => -(session.input_tokens + session.output_tokens) + + default: + return null + } +} + +/** + * The active sort key as a plain id order — the one ranking every sidebar + * surface reads. + * + * The sort key used to be applied where the flat list is assembled, so it did + * nothing at all once rows moved into groups: picking "cost" while grouped by + * project or profile left every lane in the order the backend sent it. Ranking + * lives here instead, above any one view, and each surface applies it to the + * rows it owns — the flat list within its date dividers, a group within its + * lane (and before it trims itself to a preview, so the rows it drops are the + * ones the sort key ranked last). + * + * Empty for `updated` and `manual`: recency is the order sessions already + * arrive in, and a hand-dragged sequence is the flat list's own business. + */ +export const $sidebarSessionRankIds: ReadableAtom = computed( + [$sidebarOrdering, $sessions, $sessionDotStateById], + (ordering, sessions, dotStates) => { + const rank = rankBy(ordering, dotStates) + + if (!rank) { + return UNRANKED + } + + return [...sessions].sort((a, b) => rank(a) - rank(b)).map(session => session.id) + } +)