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) + } +)