Skip to content

Commit b1d36c3

Browse files
nav: band the session list by status, then by created_at (#95)
The bottom session nav sorted by last event (open conversations first, then most-recent-event first). With several sessions in flight that means every agent response is a new event, so the list permuted on every poll and the row you were reading walked out from under you. Sort by status band, then newest-born first inside a band: 0 live — waiting / working / starting / scheduled / retrying 1 parked — sleeping / idle 2 done — completed / closed 3 dead — error / failed / stopped / cancelled `waiting` shares the live band with the in-flight statuses deliberately: a warm session crosses working -> waiting -> working on every turn, so banding those apart would reproduce the churn this change exists to remove. created_at is fixed for the life of a session, so a row only moves when it truly changes band. Drops isOpenConversation, whose only caller was the old sort. Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5eca08d commit b1d36c3

3 files changed

Lines changed: 87 additions & 53 deletions

File tree

src/lib/sessions.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -177,27 +177,35 @@ function numberField(container: unknown, key: string): number {
177177
return typeof value === 'number' && isFinite(value) ? value : 0
178178
}
179179

180-
// Whether the conversation is still open (alive/idle) — the sidebar's top
181-
// group. Terminal-status single-shot sessions and closed conversations sink
182-
// to the bottom group.
183-
export function isOpenConversation(session: AgentSession): boolean {
184-
if (session.session_state === 'closed') return false
185-
if (session.session_state === 'running' || session.session_state === 'idle') return true
186-
// No session_state (older rows, laptop syncs): treat non-terminal raw
187-
// statuses as open.
188-
return !['completed', 'error', 'cancelled', 'stopped'].includes(session.status)
180+
// The sidebar's status bands, top to bottom: live conversations, then parked
181+
// ones, then finished ones, with the dead ends last.
182+
//
183+
// `waiting` shares the top band with the in-flight statuses on purpose. A warm
184+
// session crosses working → waiting → working on EVERY turn, so banding those
185+
// apart would reorder the list on every agent response — exactly the churn
186+
// created_at ordering exists to kill. Both mean "this conversation is live";
187+
// which side of a turn it's on is the row's dot color, not its position.
188+
export function statusBand(word: string): number {
189+
if (word === 'waiting' || isActiveStatusWord(word)) return 0
190+
if (word === 'sleeping' || word === 'idle') return 1
191+
if (['error', 'failed', 'stopped', 'cancelled'].includes(word)) return 3
192+
// closed / completed and anything unrecognized settles as done.
193+
return 2
189194
}
190195

191-
// Sidebar order: open conversations first, then the rest, each group by most
192-
// recent event first. Stable for equal keys.
196+
// Sidebar order: status band, then oldest-last within the band (newest first).
197+
//
198+
// Deliberately NOT "most recent event first": with several sessions in flight
199+
// every agent response is a new event, so a recency sort permutes the list on
200+
// every poll and the row you were reading walks out from under you. Birth
201+
// order is fixed for the life of a session, so a row only ever moves when its
202+
// status band changes — a handful of times, not once per turn.
203+
//
204+
// Stable for equal keys (equal band + equal created_at keeps input order).
193205
export function sortSidebarSessions(sessions: readonly AgentSession[]): AgentSession[] {
194-
const key = (s: AgentSession): number => Date.parse(lastEventAt(s)) || 0
195-
return [...sessions].sort((a, b) => {
196-
const openA = isOpenConversation(a)
197-
const openB = isOpenConversation(b)
198-
if (openA !== openB) return openA ? -1 : 1
199-
return key(b) - key(a)
200-
})
206+
const band = (s: AgentSession): number => statusBand(rowStatusWord(s))
207+
const born = (s: AgentSession): number => Date.parse(s.created_at) || 0
208+
return [...sessions].sort((a, b) => band(a) - band(b) || born(b) - born(a))
201209
}
202210

203211
// The sidebar list: the polled snapshot plus composer-spawned sessions the

src/ui/SessionsApp.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ import { ConnectApp } from './ConnectApp'
4949
// no session is focused
5050
// 2. the chat window (the hosted ConnectApp, full width)
5151
// 3. the text input (the ConnectApp's composer)
52-
// 4. the session nav — a vertical list: "+ New session" then your five
53-
// most recent sessions
52+
// 4. the session nav — a vertical list: "+ New session" then your sessions,
53+
// banded by status (live conversations first) and newest-born first
54+
// inside a band
5455
// This is what a bare `agent`, `agent "prompt"`, and `agent session
5556
// connect <id>` all open.
5657
//
@@ -561,11 +562,12 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
561562
}
562563

563564
// ---- band 4: the session nav ----
564-
// A vertical list of six rows: the pinned new-session row, then the five
565-
// most recent sessions (status dot + description + a dim age tag), windowed
566-
// so the highlight parks on the second-to-last row and the list scrolls
567-
// under it. The band's height is fixed, so a short list leaves blank rows
568-
// rather than moving the chat above it.
565+
// A vertical list of six rows: the pinned new-session row, then five session
566+
// rows (status dot + description + a dim age tag) in sortSidebarSessions
567+
// order — status band, newest-born first — windowed so the highlight parks
568+
// on the second-to-last row and the list scrolls under it. The band's height
569+
// is fixed, so a short list leaves blank rows rather than moving the chat
570+
// above it.
569571
const selectedRowIdx = Math.max(0, rows.findIndex((s) => s.id === selected))
570572
const win = navSlice(rows.length, navSessionRows, selectedRowIdx)
571573
const navFocused = focus === 'nav'

test/sessions.test.ts

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
composerModelOptions,
77
connectability,
88
isActiveStatusWord,
9-
isOpenConversation,
109
lastEventAt,
1110
navSlice,
1211
rowDescription,
@@ -17,6 +16,7 @@ import {
1716
shortAge,
1817
sidebarSlice,
1918
sortSidebarSessions,
19+
statusBand,
2020
mergeSidebarSessions,
2121
} from '../src/lib/sessions'
2222
import { theme } from '../src/lib/theme'
@@ -245,38 +245,62 @@ describe('sessionSource', () => {
245245
})
246246
})
247247

248-
describe('sortSidebarSessions', () => {
249-
it('puts open conversations first, each group newest-event first', () => {
250-
const open1 = session({ id: 'open1', session_state: 'idle', updated_at: '2026-07-23T10:00:00Z' })
251-
const open2 = session({
252-
id: 'open2',
253-
session_state: 'running',
254-
updated_at: '2026-07-23T11:00:00Z',
255-
})
256-
const closed = session({
257-
id: 'closed',
258-
session_state: 'closed',
259-
updated_at: '2026-07-23T12:00:00Z',
260-
})
261-
const done = session({ id: 'done', status: 'completed', updated_at: '2026-07-23T13:00:00Z' })
262-
expect(sortSidebarSessions([closed, open1, done, open2]).map((s) => s.id)).toEqual([
263-
'open2',
264-
'open1',
265-
'done',
266-
'closed',
267-
])
268-
})
269-
270-
it('treats non-terminal stateless sessions as open', () => {
271-
expect(isOpenConversation(session({ status: 'running' }))).toBe(true)
272-
expect(isOpenConversation(session({ status: 'completed' }))).toBe(false)
248+
describe('statusBand / sortSidebarSessions', () => {
249+
// A row per band, deliberately born newest-first-is-wrong-order so a
250+
// recency sort can't accidentally pass.
251+
const waiting = session({
252+
id: 'waiting',
253+
created_at: '2026-07-20T00:00:00Z',
254+
surface: { session: 'alive', run: 'waiting', status: 'waiting' },
255+
})
256+
const working = session({
257+
id: 'working',
258+
created_at: '2026-07-21T00:00:00Z',
259+
surface: { session: 'alive', run: 'working', status: 'working' },
260+
})
261+
const sleeping = session({
262+
id: 'sleeping',
263+
created_at: '2026-07-22T00:00:00Z',
264+
surface: { session: 'sleeping', run: 'done', status: 'sleeping' },
265+
})
266+
const done = session({ id: 'done', created_at: '2026-07-23T00:00:00Z', status: 'completed' })
267+
const failed = session({ id: 'failed', created_at: '2026-07-24T00:00:00Z', status: 'error' })
268+
269+
it('bands by status: live, parked, done, dead', () => {
270+
expect(sortSidebarSessions([failed, done, sleeping, waiting, working]).map((s) => s.id)).toEqual(
271+
['working', 'waiting', 'sleeping', 'done', 'failed'],
272+
)
273+
expect(statusBand('completed')).toBe(statusBand('closed'))
274+
})
275+
276+
it('bands waiting with in-flight, so a finished turn does not move the row', () => {
277+
// The same session either side of a turn boundary: same band, same
278+
// created_at, so it holds its slot instead of jumping on every response.
279+
expect(statusBand('waiting')).toBe(statusBand('working'))
280+
expect(statusBand('waiting')).toBe(statusBand('starting'))
281+
const mid = sortSidebarSessions([waiting, working, sleeping]).map((s) => s.id)
282+
const after = sortSidebarSessions([
283+
{ ...waiting, surface: { session: 'alive', run: 'working', status: 'working' } },
284+
{ ...working, surface: { session: 'alive', run: 'waiting', status: 'waiting' } },
285+
sleeping,
286+
] as AgentSession[]).map((s) => s.id)
287+
expect(after).toEqual(mid)
288+
})
289+
290+
it('orders within a band newest-born first, ignoring event recency', () => {
291+
const old = session({ id: 'old', created_at: '2026-07-20T00:00:00Z', status: 'running' })
292+
const fresh = session({ id: 'fresh', created_at: '2026-07-22T00:00:00Z', status: 'running' })
293+
// `old` just spoke; that must not lift it above the younger session.
294+
const chatty = { ...old, last_activity_at: '2026-07-23T00:00:00Z' } as AgentSession
295+
expect(sortSidebarSessions([chatty, fresh]).map((s) => s.id)).toEqual(['fresh', 'old'])
296+
expect(sortSidebarSessions([fresh, chatty]).map((s) => s.id)).toEqual(['fresh', 'old'])
273297
})
274298
})
275299

276300
describe('mergeSidebarSessions', () => {
277301
it('keeps local sessions the poll has not returned yet', () => {
278-
const polled = session({ id: 'a', updated_at: '2026-07-23T10:00:00Z' })
279-
const local = session({ id: 'b', updated_at: '2026-07-23T11:00:00Z' })
302+
const polled = session({ id: 'a', created_at: '2026-07-23T10:00:00Z' })
303+
const local = session({ id: 'b', created_at: '2026-07-23T11:00:00Z' })
280304
expect(mergeSidebarSessions([polled], [local]).map((s) => s.id)).toEqual(['b', 'a'])
281305
})
282306

0 commit comments

Comments
 (0)