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:
parent
5b68d2271b
commit
8fdb92f449
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
Loading…
Reference in New Issue