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.
This commit is contained in:
Brooklyn Nicholson 2026-08-10 03:13:04 -05:00
parent 5b68d2271b
commit 8fdb92f449
4 changed files with 154 additions and 0 deletions

View File

@ -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([])

View File

@ -103,6 +103,15 @@ export function orderByIds<T>(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<T extends { id: string }>(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) {

View File

@ -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<SessionInfo>) =>
({ 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([])
})
})

View File

@ -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<string, SessionDotState>
): 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<string[]> = 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)
}
)