diff --git a/web/src/components/session-switcher.test.tsx b/web/src/components/session-switcher.test.tsx
index 1a10bf4..b28ff90 100644
--- a/web/src/components/session-switcher.test.tsx
+++ b/web/src/components/session-switcher.test.tsx
@@ -191,6 +191,25 @@ describe('SessionSwitcher', () => {
expect(props.onPick).toHaveBeenCalledWith({ machineId: 'local', sessionId: 'p2' })
})
+ it('leaves exited sessions out until a search names one, then says it ended', async () => {
+ const user = userEvent.setup()
+ mount({
+ sessions: [
+ s({ id: 'a', name: 'pnpm build' }),
+ s({ id: 'over', name: 'old deploy', state: 'exited' }),
+ ],
+ })
+ // At rest the palette is a "take me there" control, and a dead session is
+ // not a there.
+ expect(rowNames().join(' ')).not.toContain('old deploy')
+
+ await user.type(field(), 'deploy')
+
+ const row = screen.getAllByRole('option')[0]!
+ expect(row.textContent).toContain('old deploy')
+ expect(row.textContent).toContain('exited')
+ })
+
it('shows a remembered session whose machine has gone, and still opens it', async () => {
const user = userEvent.setup()
const { props } = mount({
diff --git a/web/src/components/session-switcher.tsx b/web/src/components/session-switcher.tsx
index 26f22fc..aef2f09 100644
--- a/web/src/components/session-switcher.tsx
+++ b/web/src/components/session-switcher.tsx
@@ -375,6 +375,12 @@ function Hint({ keys, what }: { keys: string; what: string }) {
* a machine this browser cannot reach right now can say nothing about what is
* happening inside it, and a confident green dot over an unreachable machine
* would be the list inventing news.
+ *
+ * An ended row also says so in a word — "exited", the sessions screen's own
+ * word for it — as a ghost says "unreachable". It can afford the space: ended
+ * rows only appear as search results (switcher/order.ts keeps them out of the
+ * resting palette), where the reader is choosing between matches and "this
+ * one is over" is the fact that decides.
*/
function Row({
row,
@@ -425,6 +431,8 @@ function Row({
{rowMachine(row)}
{ghost ? (
unreachable
+ ) : ended ? (
+ exited
) : row.badge !== null ? (
⌃⇧{row.badge}
) : null}
diff --git a/web/src/routes/sessions.test.tsx b/web/src/routes/sessions.test.tsx
index 8ab1d0f..2ab5b0c 100644
--- a/web/src/routes/sessions.test.tsx
+++ b/web/src/routes/sessions.test.tsx
@@ -257,6 +257,9 @@ describe('SessionsRoute', () => {
const { sock } = await mountSessions()
listed(sock, [info({ id: 's1' }), info({ id: 's2', state: 'exited' })])
+ // The ended session has to be on show before State can have two headings:
+ // the default folds it away.
+ await user.click(screen.getByRole('button', { name: 'Show exited sessions' }))
await user.click(screen.getByRole('button', { name: 'Display options' }))
await pick(user, 'Grouping', 'State')
@@ -264,6 +267,48 @@ describe('SessionsRoute', () => {
expect(screen.getByRole('button', { name: 'Exited' })).toBeTruthy()
})
+ describe('the ended fold', () => {
+ it('folds ended sessions away by default and says how many', async () => {
+ const { sock } = await mountSessions()
+ listed(sock, [
+ info({ id: 's1', cwd: '/live' }),
+ info({ id: 's2', cwd: '/dead', state: 'exited' }),
+ info({ id: 's3', cwd: '/dead-too', state: 'exited' }),
+ ])
+
+ expect(screen.getByText('/live')).toBeTruthy()
+ expect(screen.queryByText('/dead')).toBeNull()
+ expect(screen.getByText('exited sessions')).toBeTruthy()
+ expect(screen.getByText('2')).toBeTruthy()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Show exited sessions' }))
+
+ expect(screen.getByText('/dead')).toBeTruthy()
+ expect(screen.getByText('/dead-too')).toBeTruthy()
+ // An open fold hides nothing, so the count would be a claim about
+ // nothing; the display options own the way back.
+ expect(screen.queryByRole('button', { name: 'Show exited sessions' })).toBeNull()
+ })
+
+ it('does not claim an empty fleet when everything on it has ended', async () => {
+ // "No sessions yet" is a claim about the fleet, and a fleet whose every
+ // session has ended is not one that has none — the fold line stands in
+ // for the rows it hides.
+ const { sock, welcomeLocal, attic } = await mountSessions()
+ welcomeLocal()
+ act(() => attic.sockets[0]!.open())
+ listed(sock, [info({ id: 's1', cwd: '/dead', state: 'exited' })])
+ listed(attic.sockets[0]!, [])
+
+ expect(screen.queryByText(/No sessions yet/i)).toBeNull()
+ expect(screen.getByText('exited session')).toBeTruthy()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Show exited sessions' }))
+
+ expect(screen.getByText('/dead')).toBeTruthy()
+ })
+ })
+
it('opens a session on the machine that owns it, from the row itself', async () => {
const { sock, attic, router } = await mountSessions()
act(() => attic.sockets[0]!.open())
@@ -886,6 +931,9 @@ describe('SessionsRoute', () => {
info({ id: 's2', state: 'exited', exitCode: 1 }),
])
await user.click(screen.getByRole('button', { name: 'Display options' }))
+ // The default folds the ended session away, and a heading that is not
+ // there can prove nothing about its controls.
+ await user.click(screen.getByRole('checkbox', { name: 'Show exited sessions' }))
await pick(user, 'Grouping', 'State')
await user.keyboard('{Escape}')
@@ -952,29 +1000,30 @@ describe('SessionsRoute', () => {
const { sock } = await mountSessions()
listed(sock, [info({ id: 's1', cwd: '/live' }), info({ id: 's2', cwd: '/dead', state: 'exited' })])
- // Arrange: ended sessions out, then keep that under a name.
+ // Arrange: ended sessions in — the default folds them away — then keep
+ // that under a name.
await user.click(screen.getByRole('button', { name: 'Display options' }))
await user.click(screen.getByRole('checkbox', { name: 'Show exited sessions' }))
await user.keyboard('{Escape}')
- expect(screen.queryByText('/dead')).toBeNull()
+ expect(screen.getByText('/dead')).toBeTruthy()
await saveAs(user, 'Ops')
expect(screen.getByRole('button', { name: 'Ops' }).getAttribute('aria-pressed')).toBe('true')
- // All is the built-in default: everything comes back.
+ // All is the built-in default: the ended session folds away again.
await user.click(screen.getByRole('button', { name: 'All' }))
- expect(screen.getByText('/dead')).toBeTruthy()
+ expect(screen.queryByText('/dead')).toBeNull()
// And the tab re-applies what it kept.
await user.click(screen.getByRole('button', { name: 'Ops' }))
- expect(screen.queryByText('/dead')).toBeNull()
+ expect(screen.getByText('/dead')).toBeTruthy()
const kept = JSON.parse(localStorage.getItem('flue.views')!) as Array<{
name: string
showExited: boolean
}>
expect(kept).toHaveLength(1)
- expect(kept[0]).toMatchObject({ name: 'Ops', showExited: false })
+ expect(kept[0]).toMatchObject({ name: 'Ops', showExited: true })
})
it('marks dirty by value, so an edit undone is no edit at all', async () => {
@@ -1036,6 +1085,7 @@ describe('SessionsRoute', () => {
const first = await mountSessions()
listed(first.sock, [info({ id: 's1' }), info({ id: 's2', state: 'exited' })])
await user.click(screen.getByRole('button', { name: 'Display options' }))
+ await user.click(screen.getByRole('checkbox', { name: 'Show exited sessions' }))
await pick(user, 'Grouping', 'State')
await user.keyboard('{Escape}')
first.unmount()
diff --git a/web/src/routes/sessions.tsx b/web/src/routes/sessions.tsx
index 2022928..216086f 100644
--- a/web/src/routes/sessions.tsx
+++ b/web/src/routes/sessions.tsx
@@ -32,6 +32,7 @@ import {
applyView,
DEFAULT_VIEW,
displayName,
+ hiddenExited,
spawnFromGroup,
type Group,
type ViewConfig,
@@ -391,6 +392,8 @@ export function SessionsRoute() {
const machines = fleetState?.machines ?? null
const groups = useMemo(() => applyView(sessions, view), [sessions, view])
+ /** How many rows the view folded away for having ended. See hiddenExited. */
+ const hiddenEnded = useMemo(() => hiddenExited(sessions, view), [sessions, view])
const byKey = useMemo(() => new Map(sessions.map((s) => [keyOf(s), s])), [sessions])
const knownTags = useMemo(
() => [...new Set(sessions.flatMap((s) => s.tags))].sort(),
@@ -624,10 +627,13 @@ export function SessionsRoute() {
* machine still dialling, and at least one machine actually online — "No
* sessions yet" from a screen whose machines are all down would be a lie
* the placeholders and the unreachable bands are there to avoid telling.
+ * A list that is only empty because it folded its ended sessions away is
+ * not empty either; the fold line below stands in for the rows it hides.
*/
const showTable =
fleetState !== null &&
- (groups.length > 0 || (settled && connecting.length === 0 && online.length > 0))
+ (groups.length > 0 ||
+ (settled && connecting.length === 0 && online.length > 0 && hiddenEnded === 0))
/** Placeholder rows, for machines that have not had the chance to answer. */
const showSkeleton =
@@ -758,6 +764,13 @@ export function SessionsRoute() {
/>
)}
+ {hiddenEnded > 0 && (
+ setView((v) => ({ ...v, showExited: true }))}
+ />
+ )}
+
{showSkeleton && }
{view.grouping === 'machine' &&
@@ -949,6 +962,38 @@ function FleetGapBand({ gaps }: { gaps: FleetGaps }) {
)
}
+/**
+ * The ended sessions the view folded away, counted where their rows would
+ * have ended up — under the list, since every ordering reads the living
+ * before the dead.
+ *
+ * A line and not a band: the bands above it report problems, and a fold is
+ * an arrangement working as designed. It exists because the default hides
+ * ended sessions, and a default that hid them without saying so would make
+ * an emptied list read as an empty fleet — the count keeps them discoverable
+ * from the screen itself rather than from a checkbox in the display options.
+ * Show sets the same `showExited` the checkbox owns, so the reveal is a view
+ * edit like any other: the dirty flag lights, the choice persists, and the
+ * checkbox is already ticked for whoever goes looking for the way back.
+ *
+ * "Exited", not a softer word, because it is the word this screen already
+ * uses everywhere it counts these sessions — the state heading, the group
+ * headcounts, the checkbox — and the fold naming them anything else would
+ * read as a third kind of session.
+ */
+function ExitedFold({ count, onShow }: { count: number; onShow(): void }) {
+ return (
+
+ )
+}
+
/**
* A machine the fleet cannot reach right now, said in a muted band where its
* rows would have been. Retry redials that one machine's client, and does it
diff --git a/web/src/sessions/view.test.ts b/web/src/sessions/view.test.ts
index ccb8404..e265a72 100644
--- a/web/src/sessions/view.test.ts
+++ b/web/src/sessions/view.test.ts
@@ -11,6 +11,7 @@ import {
GROUPING_LABELS,
GROUPINGS,
groupSessions,
+ hiddenExited,
ORDERING_LABELS,
ORDERINGS,
orderSessions,
@@ -395,7 +396,12 @@ describe('DEFAULT_VIEW', () => {
expect(DEFAULT_VIEW.grouping).toBe('machine')
expect(DEFAULT_VIEW.ordering).toBe('lastActive')
expect(DEFAULT_VIEW.search).toBe('')
- expect(DEFAULT_VIEW.showExited).toBe(true)
+ })
+
+ it('folds the ended sessions away', () => {
+ // An exited session is history, not somewhere to go; the list opens on
+ // what is running, and `hiddenExited` is what keeps the fold honest.
+ expect(DEFAULT_VIEW.showExited).toBe(false)
})
it('shows every column but the creation time', () => {
@@ -494,6 +500,27 @@ describe('applyView', () => {
})
})
+describe('hiddenExited', () => {
+ const rows = [
+ s({ id: 'live', cwd: '/code/flue' }),
+ s({ id: 'gone', cwd: '/srv/db', state: 'exited' }),
+ s({ id: 'gone-too', cwd: '/srv/cache', state: 'exited' }),
+ ]
+
+ it('counts what the fold is hiding', () => {
+ expect(hiddenExited(rows, { ...DEFAULT_VIEW, showExited: false })).toBe(2)
+ })
+
+ it('is zero when the view shows them, since an open fold hides nothing', () => {
+ expect(hiddenExited(rows, { ...DEFAULT_VIEW, showExited: true })).toBe(0)
+ })
+
+ it('counts inside the search, so the sentence matches the list it sits under', () => {
+ expect(hiddenExited(rows, { ...DEFAULT_VIEW, showExited: false, search: 'srv/db' })).toBe(1)
+ expect(hiddenExited(rows, { ...DEFAULT_VIEW, showExited: false, search: 'flue' })).toBe(0)
+ })
+})
+
describe('spawnFromGroup', () => {
it('hands a machine heading its own machine', () => {
expect(spawnFromGroup('machine', 'machine:m1')).toEqual({ machineId: 'm1' })
diff --git a/web/src/sessions/view.ts b/web/src/sessions/view.ts
index eb768f3..bb7db9e 100644
--- a/web/src/sessions/view.ts
+++ b/web/src/sessions/view.ts
@@ -107,11 +107,14 @@ export interface ViewConfig {
*
* Grouped by machine because the fleet is the reason this screen was rebuilt —
* "what is running where" is the first question, and a heading per machine
- * answers it before a single row is read. Ended sessions stay in, because a
- * session that exited three minutes ago with a non-zero code is exactly what
- * someone comes here to find; hiding it by default would make the screen
- * quietly lie about what happened. Every column but the creation time, which
- * is the one people ask for rarely and can turn on.
+ * answers it before a single row is read. Ended sessions start hidden: an
+ * exited session is history rather than somewhere to go, and a list that
+ * opens on last week's dead shells buries the rows someone came to act on.
+ * They are folded away rather than gone — `hiddenExited` counts them, the
+ * screen says the count out loud, and one press brings them back — so the
+ * session that exited three minutes ago with a non-zero code is still one
+ * click from being found. Every column but the creation time, which is the
+ * one people ask for rarely and can turn on.
*
* Frozen, and its column list with it, because this is one object shared by
* every browser tab that has never saved an arrangement. `ViewConfig` is a
@@ -126,7 +129,7 @@ export const DEFAULT_VIEW: ViewConfig = Object.freeze({
ordering: 'lastActive',
search: '',
columns: frozen(['name', 'directory', 'machine', 'tags', 'state', 'lastActive']),
- showExited: true,
+ showExited: false,
})
/**
@@ -439,3 +442,20 @@ export function applyView(list: FleetSession[], v: ViewConfig): Group[] {
const wanted = v.showExited ? matched : matched.filter((s) => s.state !== 'exited')
return groupSessions(orderSessions(wanted, v.ordering), v.grouping)
}
+
+/**
+ * How many sessions the view folded away for having ended.
+ *
+ * The number the screen says out loud under the list: a default that hides
+ * ended sessions silently would make an emptied list read as an empty fleet,
+ * and the way back — the display options' checkbox — is set somewhere the
+ * reader has no reason to look. Counted after the search for the same reason
+ * `applyView` filters after it: the sentence sits under a searched list, and
+ * "3 ended" must mean three the reader would see, not three somewhere in the
+ * fleet. Zero whenever the view shows them, because a fold that is open hides
+ * nothing.
+ */
+export function hiddenExited(list: FleetSession[], v: ViewConfig): number {
+ if (v.showExited) return 0
+ return filterSessions(list, v.search).filter((s) => s.state === 'exited').length
+}
diff --git a/web/src/switcher/order.test.ts b/web/src/switcher/order.test.ts
index c3728a3..9c6b9d4 100644
--- a/web/src/switcher/order.test.ts
+++ b/web/src/switcher/order.test.ts
@@ -171,6 +171,46 @@ describe('buildPalette, resting', () => {
['local/b', false],
])
})
+
+ it('offers no exited session, pinned or not', () => {
+ // A dead session is not a there, and every ended row is one more press
+ // between somebody and the session they meant.
+ const palette = buildPalette({
+ sessions: [
+ s({ id: 'dead-pin', pinned: true, state: 'exited' }),
+ s({ id: 'live-pin', pinned: true, createdAt: '2026-02-01T00:00:00Z' }),
+ s({ id: 'dead', state: 'exited' }),
+ s({ id: 'live' }),
+ ],
+ recents: [],
+ search: '',
+ })
+ expect(keys(palette)).toEqual(['local/live-pin', 'local/live'])
+ })
+
+ it('gives an exited pinned session no badge, and its number to the next', () => {
+ // The chord picks from the palette's own pinned run, so a dead row that
+ // kept a number would make ⌃⇧1 mean a session nobody can switch to.
+ const sessions = [
+ s({ id: 'dead', pinned: true, state: 'exited', createdAt: '2026-01-01T00:00:00Z' }),
+ s({ id: 'live', pinned: true, createdAt: '2026-02-01T00:00:00Z' }),
+ ]
+ const rows = flatten(buildPalette({ sessions, recents: [], search: '' }))
+ expect(rows.map((r) => [r.key, r.kind === 'live' ? r.badge : null])).toEqual([
+ ['local/live', 1],
+ ])
+ })
+
+ it('skips a visited session that has since exited, and does not ghost it', () => {
+ // Its machine is answering, so unlike a ghost there is no sleeping
+ // laptop this row could wake.
+ const palette = buildPalette({
+ sessions: [s({ id: 'a' }), s({ id: 'over', state: 'exited' })],
+ recents: [v({ sessionId: 'over' })],
+ search: '',
+ })
+ expect(keys(palette)).toEqual(['local/a'])
+ })
})
describe('buildPalette, searching', () => {
@@ -217,6 +257,30 @@ describe('buildPalette, searching', () => {
const palette = buildPalette({ sessions: [s({ id: 'a' })], recents: [], search: 'nothing' })
expect(palette.sections).toEqual([])
})
+
+ it('brings an exited session back once it is named', () => {
+ // Typing the exact name of a session you know ended and getting nothing
+ // would be its own kind of wrong.
+ const palette = buildPalette({
+ sessions: [s({ id: 'over', name: 'build', state: 'exited' })],
+ recents: [],
+ search: 'build',
+ })
+ expect(keys(palette)).toEqual(['local/over'])
+ })
+
+ it('ranks the ended matches after the running ones, and both before the ghosts', () => {
+ const palette = buildPalette({
+ sessions: [
+ // The ended row is the more recently active, and loses anyway.
+ s({ id: 'over', name: 'build old', state: 'exited', lastActive: '2026-06-01T00:00:00Z' }),
+ s({ id: 'live', name: 'build now' }),
+ ],
+ recents: [v({ machineId: 'studio', sessionId: 'asleep', label: 'build there' })],
+ search: 'build',
+ })
+ expect(keys(palette)).toEqual(['local/live', 'local/over', 'studio/asleep'])
+ })
})
describe('the cap', () => {
@@ -310,4 +374,18 @@ describe('the cycle', () => {
it('has nowhere to step in an empty fleet', () => {
expect(stepCycle([], 'local/a', 1)).toBeNull()
})
+
+ it('walks past the exited, so a blind hop cannot land on a dead session', () => {
+ const sessions = [
+ s({ id: 'a' }),
+ s({ id: 'over', state: 'exited', createdAt: '2026-02-01T00:00:00Z' }),
+ s({ id: 'b', createdAt: '2026-03-01T00:00:00Z' }),
+ ]
+ const order = cycleOrder(sessions)
+ expect(order.map((x) => x.id)).toEqual(['a', 'b'])
+ expect(stepCycle(order, 'local/a', 1)?.id).toBe('b')
+ // Pressed from inside a session that has just ended, the chord still
+ // leaves: the walk starts from the top rather than refusing.
+ expect(stepCycle(order, 'local/over', 1)?.id).toBe('a')
+ })
})
diff --git a/web/src/switcher/order.ts b/web/src/switcher/order.ts
index 4e40c28..e79634e 100644
--- a/web/src/switcher/order.ts
+++ b/web/src/switcher/order.ts
@@ -89,6 +89,15 @@ export interface BuildOptions {
* A search collapses all of that into one run: once someone is typing, headings
* are furniture between them and the match. Ghosts sort to the end of it, since
* a session that can be opened right now beats one whose machine is asleep.
+ *
+ * Exited sessions are not offered at rest at all. This palette is a "take me
+ * there" control, and a dead session is not a there — every ended row is one
+ * more press between somebody and the session they meant. A search brings them
+ * back: typing the exact name of a session you know ended and getting nothing
+ * would be its own kind of wrong, so a match is shown, marked as ended, and
+ * ranked after everything running. Ghosts are a different kind of unreachable
+ * and keep their resting rows — an offline machine comes back, an exited shell
+ * does not.
*/
export function buildPalette({
sessions,
@@ -114,15 +123,23 @@ function restingSections(
live: Map,
currentKey: string | null,
): SwitcherSection[] {
- const pinned = pinnedOrder(sessions)
+ // The dead do not rest here — see the ended-sessions note on buildPalette.
+ const usable = sessions.filter((s) => s.state !== 'exited')
+ const pinned = pinnedOrder(usable)
const spoken = new Set(pinned.map(keyOf))
const recentRows: SwitcherRow[] = []
for (const visit of recents) {
const key = visitKey(visit)
if (spoken.has(key)) continue
+ // Spoken for even when the next line skips it: a session deliberately
+ // left out of Recent must not resurface further down the palette.
spoken.add(key)
const session = live.get(key)
+ // A visited session the fleet reports as exited is remembered, not
+ // offered: its machine is answering, so unlike a ghost there is no
+ // sleeping laptop this row could wake.
+ if (session !== undefined && session.state === 'exited') continue
recentRows.push(
session
? { kind: 'live', key, session, badge: null, current: key === currentKey }
@@ -134,7 +151,7 @@ function restingSections(
// in the sessions screen's own default order so the two screens agree about
// what "recently busy" means.
const rest = orderSessions(
- sessions.filter((s) => !spoken.has(keyOf(s))),
+ usable.filter((s) => !spoken.has(keyOf(s))),
'lastActive',
).map((session) => liveRow(session, null, currentKey))
@@ -153,9 +170,14 @@ function searchSections(
needle: string,
currentKey: string | null,
): SwitcherSection[] {
- const hits = orderSessions(filterSessions(sessions, needle), 'lastActive').map((s) =>
- liveRow(s, null, currentKey),
- )
+ // Ended sessions rejoin the list once they are named, ranked after
+ // everything running: a match that can be switched to beats one that can
+ // only be read about.
+ const matched = orderSessions(filterSessions(sessions, needle), 'lastActive')
+ const hits = [
+ ...matched.filter((s) => s.state !== 'exited'),
+ ...matched.filter((s) => s.state === 'exited'),
+ ].map((s) => liveRow(s, null, currentKey))
const ghosts = recents
.filter((v) => !live.has(visitKey(v)) && matchesVisit(v, needle))
.map((visit) => ({
@@ -258,10 +280,14 @@ export function openingRow(palette: Palette): SwitcherRow | null {
*
* Live sessions only, and that asymmetry with the palette is on purpose. A row
* a reader deliberately picked out of a list can be a sleeping machine — they
- * saw what they chose. A blind hop must land somewhere usable.
+ * saw what they chose. A blind hop must land somewhere usable — which also
+ * rules out exited sessions, here and not only here: the palette excludes
+ * them from its resting rows and its pinned badges, so a cycle that kept them
+ * would have ⌃⇧2 mean one session with the palette open and another with it
+ * shut.
*/
export function cycleOrder(sessions: FleetSession[]): FleetSession[] {
- return [...sessions].sort(
+ return sessions.filter((s) => s.state !== 'exited').sort(
(a, b) =>
Number(b.pinned) - Number(a.pinned) ||
Date.parse(a.createdAt) - Date.parse(b.createdAt) ||