diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 5500d6e..b16cbd8 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -177,27 +177,35 @@ function numberField(container: unknown, key: string): number { return typeof value === 'number' && isFinite(value) ? value : 0 } -// Whether the conversation is still open (alive/idle) — the sidebar's top -// group. Terminal-status single-shot sessions and closed conversations sink -// to the bottom group. -export function isOpenConversation(session: AgentSession): boolean { - if (session.session_state === 'closed') return false - if (session.session_state === 'running' || session.session_state === 'idle') return true - // No session_state (older rows, laptop syncs): treat non-terminal raw - // statuses as open. - return !['completed', 'error', 'cancelled', 'stopped'].includes(session.status) +// The sidebar's status bands, top to bottom: live conversations, then parked +// ones, then finished ones, with the dead ends last. +// +// `waiting` shares the top band with the in-flight statuses on purpose. A warm +// session crosses working → waiting → working on EVERY turn, so banding those +// apart would reorder the list on every agent response — exactly the churn +// created_at ordering exists to kill. Both mean "this conversation is live"; +// which side of a turn it's on is the row's dot color, not its position. +export function statusBand(word: string): number { + if (word === 'waiting' || isActiveStatusWord(word)) return 0 + if (word === 'sleeping' || word === 'idle') return 1 + if (['error', 'failed', 'stopped', 'cancelled'].includes(word)) return 3 + // closed / completed and anything unrecognized settles as done. + return 2 } -// Sidebar order: open conversations first, then the rest, each group by most -// recent event first. Stable for equal keys. +// Sidebar order: status band, then oldest-last within the band (newest first). +// +// Deliberately NOT "most recent event first": with several sessions in flight +// every agent response is a new event, so a recency sort permutes the list on +// every poll and the row you were reading walks out from under you. Birth +// order is fixed for the life of a session, so a row only ever moves when its +// status band changes — a handful of times, not once per turn. +// +// Stable for equal keys (equal band + equal created_at keeps input order). export function sortSidebarSessions(sessions: readonly AgentSession[]): AgentSession[] { - const key = (s: AgentSession): number => Date.parse(lastEventAt(s)) || 0 - return [...sessions].sort((a, b) => { - const openA = isOpenConversation(a) - const openB = isOpenConversation(b) - if (openA !== openB) return openA ? -1 : 1 - return key(b) - key(a) - }) + const band = (s: AgentSession): number => statusBand(rowStatusWord(s)) + const born = (s: AgentSession): number => Date.parse(s.created_at) || 0 + return [...sessions].sort((a, b) => band(a) - band(b) || born(b) - born(a)) } // The sidebar list: the polled snapshot plus composer-spawned sessions the diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index ad0803c..dd2bc4e 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -49,8 +49,9 @@ import { ConnectApp } from './ConnectApp' // no session is focused // 2. the chat window (the hosted ConnectApp, full width) // 3. the text input (the ConnectApp's composer) -// 4. the session nav — a vertical list: "+ New session" then your five -// most recent sessions +// 4. the session nav — a vertical list: "+ New session" then your sessions, +// banded by status (live conversations first) and newest-born first +// inside a band // This is what a bare `agent`, `agent "prompt"`, and `agent session // connect ` all open. // @@ -561,11 +562,12 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { } // ---- band 4: the session nav ---- - // A vertical list of six rows: the pinned new-session row, then the five - // most recent sessions (status dot + description + a dim age tag), windowed - // so the highlight parks on the second-to-last row and the list scrolls - // under it. The band's height is fixed, so a short list leaves blank rows - // rather than moving the chat above it. + // A vertical list of six rows: the pinned new-session row, then five session + // rows (status dot + description + a dim age tag) in sortSidebarSessions + // order — status band, newest-born first — windowed so the highlight parks + // on the second-to-last row and the list scrolls under it. The band's height + // is fixed, so a short list leaves blank rows rather than moving the chat + // above it. const selectedRowIdx = Math.max(0, rows.findIndex((s) => s.id === selected)) const win = navSlice(rows.length, navSessionRows, selectedRowIdx) const navFocused = focus === 'nav' diff --git a/test/sessions.test.ts b/test/sessions.test.ts index afa9171..376c91a 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -6,7 +6,6 @@ import { composerModelOptions, connectability, isActiveStatusWord, - isOpenConversation, lastEventAt, navSlice, rowDescription, @@ -17,6 +16,7 @@ import { shortAge, sidebarSlice, sortSidebarSessions, + statusBand, mergeSidebarSessions, } from '../src/lib/sessions' import { theme } from '../src/lib/theme' @@ -245,38 +245,62 @@ describe('sessionSource', () => { }) }) -describe('sortSidebarSessions', () => { - it('puts open conversations first, each group newest-event first', () => { - const open1 = session({ id: 'open1', session_state: 'idle', updated_at: '2026-07-23T10:00:00Z' }) - const open2 = session({ - id: 'open2', - session_state: 'running', - updated_at: '2026-07-23T11:00:00Z', - }) - const closed = session({ - id: 'closed', - session_state: 'closed', - updated_at: '2026-07-23T12:00:00Z', - }) - const done = session({ id: 'done', status: 'completed', updated_at: '2026-07-23T13:00:00Z' }) - expect(sortSidebarSessions([closed, open1, done, open2]).map((s) => s.id)).toEqual([ - 'open2', - 'open1', - 'done', - 'closed', - ]) - }) - - it('treats non-terminal stateless sessions as open', () => { - expect(isOpenConversation(session({ status: 'running' }))).toBe(true) - expect(isOpenConversation(session({ status: 'completed' }))).toBe(false) +describe('statusBand / sortSidebarSessions', () => { + // A row per band, deliberately born newest-first-is-wrong-order so a + // recency sort can't accidentally pass. + const waiting = session({ + id: 'waiting', + created_at: '2026-07-20T00:00:00Z', + surface: { session: 'alive', run: 'waiting', status: 'waiting' }, + }) + const working = session({ + id: 'working', + created_at: '2026-07-21T00:00:00Z', + surface: { session: 'alive', run: 'working', status: 'working' }, + }) + const sleeping = session({ + id: 'sleeping', + created_at: '2026-07-22T00:00:00Z', + surface: { session: 'sleeping', run: 'done', status: 'sleeping' }, + }) + const done = session({ id: 'done', created_at: '2026-07-23T00:00:00Z', status: 'completed' }) + const failed = session({ id: 'failed', created_at: '2026-07-24T00:00:00Z', status: 'error' }) + + it('bands by status: live, parked, done, dead', () => { + expect(sortSidebarSessions([failed, done, sleeping, waiting, working]).map((s) => s.id)).toEqual( + ['working', 'waiting', 'sleeping', 'done', 'failed'], + ) + expect(statusBand('completed')).toBe(statusBand('closed')) + }) + + it('bands waiting with in-flight, so a finished turn does not move the row', () => { + // The same session either side of a turn boundary: same band, same + // created_at, so it holds its slot instead of jumping on every response. + expect(statusBand('waiting')).toBe(statusBand('working')) + expect(statusBand('waiting')).toBe(statusBand('starting')) + const mid = sortSidebarSessions([waiting, working, sleeping]).map((s) => s.id) + const after = sortSidebarSessions([ + { ...waiting, surface: { session: 'alive', run: 'working', status: 'working' } }, + { ...working, surface: { session: 'alive', run: 'waiting', status: 'waiting' } }, + sleeping, + ] as AgentSession[]).map((s) => s.id) + expect(after).toEqual(mid) + }) + + it('orders within a band newest-born first, ignoring event recency', () => { + const old = session({ id: 'old', created_at: '2026-07-20T00:00:00Z', status: 'running' }) + const fresh = session({ id: 'fresh', created_at: '2026-07-22T00:00:00Z', status: 'running' }) + // `old` just spoke; that must not lift it above the younger session. + const chatty = { ...old, last_activity_at: '2026-07-23T00:00:00Z' } as AgentSession + expect(sortSidebarSessions([chatty, fresh]).map((s) => s.id)).toEqual(['fresh', 'old']) + expect(sortSidebarSessions([fresh, chatty]).map((s) => s.id)).toEqual(['fresh', 'old']) }) }) describe('mergeSidebarSessions', () => { it('keeps local sessions the poll has not returned yet', () => { - const polled = session({ id: 'a', updated_at: '2026-07-23T10:00:00Z' }) - const local = session({ id: 'b', updated_at: '2026-07-23T11:00:00Z' }) + const polled = session({ id: 'a', created_at: '2026-07-23T10:00:00Z' }) + const local = session({ id: 'b', created_at: '2026-07-23T11:00:00Z' }) expect(mergeSidebarSessions([polled], [local]).map((s) => s.id)).toEqual(['b', 'a']) })