Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions src/ui/SessionsApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` all open.
//
Expand Down Expand Up @@ -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'
Expand Down
80 changes: 52 additions & 28 deletions test/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
composerModelOptions,
connectability,
isActiveStatusWord,
isOpenConversation,
lastEventAt,
navSlice,
rowDescription,
Expand All @@ -17,6 +16,7 @@ import {
shortAge,
sidebarSlice,
sortSidebarSessions,
statusBand,
mergeSidebarSessions,
} from '../src/lib/sessions'
import { theme } from '../src/lib/theme'
Expand Down Expand Up @@ -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'])
})

Expand Down