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 ( +

+ {count} + {count === 1 ? 'exited session' : 'exited sessions'} + + +

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