diff --git a/src/web/public/constants.js b/src/web/public/constants.js index ea38016b..971e3796 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2285,7 +2285,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) { /** * The sessions the Tiles button can open (case c of tileGridOpenSet, and the - * ones a count fills a grid with), in tab order: live ones only, never a session popped out to + * ones a count fills a grid with), in the order given (tab order, or the + * ranking): live ones only, never a session popped out to * its own window (that window owns its PTY size). A session with no PTY * attached IS offered: its tile shows the Attach overlay. * @@ -2299,29 +2300,105 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) { for (const id of sessionOrder) { if (detachedIds?.has?.(id)) continue; const session = sessions.get(id); - if (!session) continue; + if (!session || result.some((r) => r.id === id)) continue; result.push({ id, label: session.name || 'Session' }); } return result; } +// Ranking groups (rankTileSessions): working first, then the sessions waiting +// on the user, then everything else. +const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 }; + +/** + * The stamp a session is ranked by inside its group. A WORKING session keys + * off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints + * about once a second, so its last-activity stamp is always "now", and one + * that never submitted would otherwise claim the head of the group. 0 means + * unknown. Every other state is the home screens' anchor (sessionActivityAnchor: + * the last byte the pane printed, i.e. when it went quiet). + */ +function tileRankStamp(row) { + if (row.state === 'working') return Number(row.lastSubmitAt) || 0; + return sessionActivityAnchor(row); +} + +/** + * Which open sessions the tile grid shows when nobody said which (the Tiles + * button with no stored grid to bring back, and every place the grid fills a + * tile on its own: a count picked in its menu, a freed cell), best first + * (owner request: "prefer to load in tiles that are working and then the most + * recent, so the oldest don't get opened"): + * 1. WORKING, the most recently started turn first; + * 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a + * permission or question dialog; `waiting`: a finished turn not seen + * yet), most recent first; + * 3. then every other one (idle, done, error), most recently active first. + * Inside a group the newest stamp wins (tileRankStamp) and a session with no + * stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so + * the result never shuffles. The states are the home screens' own + * (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the + * order differs: the home screens put the longest-running turn first, the grid + * the most recent. + * + * Pure. Unit-tested in test/tile-grid-ranking.test.ts. + * + * @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows + * @returns {string[]} the ids, best first, each once + */ +function rankTileSessions(rows) { + const group = (row) => TILE_RANK_GROUP[row.state] ?? 2; + const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id); + list.sort((a, b) => { + const byGroup = group(a) - group(b); + if (byGroup !== 0) return byGroup; + const atA = tileRankStamp(a); + const atB = tileRankStamp(b); + if (atA !== atB) { + if (!atA) return 1; + if (!atB) return -1; + return atB - atA; + } + const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER; + const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + const ids = []; + for (const row of list) if (!ids.includes(row.id)) ids.push(row.id); + return ids; +} + /** * What the Tiles button and Ctrl+Shift+G open, at once and without asking * (owner decision 8). In order: * a. the grid this tab last had (`stored`, already sanitized: live, not * detached, at most the cap), if any of its sessions survive; * b. else an open split's two sessions, Pane A focused; - * c. else the open sessions in tab order (buildTilePickerSessions: no - * detached ones), up to `limit`, the active session always among them and focused - * (when it sits past the limit, the first `limit - 1` others come with it). + * c. else the open sessions in `ranked` order (rankTileSessions: working, + * then needing input, then the most recent; tab order when no ranking is + * given), detached ones never, up to `limit`, the active session always + * among them and focused (when it ranks past the limit, the first + * `limit - 1` others come with it). * Null when there is nothing to open. * * @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null, - * split?: string[]|null, sessions: Map, sessionOrder: string[], + * split?: string[]|null, ranked?: string[]|null, sessions: Map, sessionOrder: string[], * detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p - * @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null} + * @returns {{source: 'stored'|'split'|'ranked', ids: string[], focusedId: string|null}|null} */ -function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) { +function tileGridOpenSet({ + stored = null, + split = null, + ranked = null, + sessions, + sessionOrder, + detachedIds, + activeId = null, + limit, +}) { + const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map( + (c) => c.id + ); if (stored?.ids?.length) { const focus = stored.zoomed || stored.focused; return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] }; @@ -2330,13 +2407,12 @@ function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, const pair = (split || []).filter(usable); if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] }; const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX)); - const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id); if (all.length === 0) return null; let ids = all.slice(0, max); if (all.includes(activeId) && !ids.includes(activeId)) { ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId]; } - return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] }; + return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] }; } /** @@ -2356,8 +2432,9 @@ function sanitizeTileCount(raw) { * `base` (what the grid would open, or what an open grid shows, in its order) * trimmed or filled to `n` tiles: trimmed from the end, the session to focus * (`keepId`) always kept (it takes the last place when it sat past `n`, as in - * tileGridOpenSet's case c); filled from `all` (the open sessions in tab order) - * with the ones not in it yet. Fewer sessions than `n` give fewer tiles. + * tileGridOpenSet's case c); filled from `all` (the open sessions, best first: + * the app passes the ranking, rankTileSessions) with the ones not in it yet. + * Fewer sessions than `n` give fewer tiles. * * @param {string[]} base * @param {string[]} all @@ -2866,6 +2943,7 @@ if (typeof window !== 'undefined') { fitTileCells, cycleTile, tileGridOpenSet, + rankTileSessions, sanitizeTileCount, tileGridSetForCount, tileCellCols, diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index dcb5e6da..02c4d5f4 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -1012,8 +1012,8 @@ Object.assign(CodemanApp.prototype, { } const T = window.CodemanTileGrid; const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity); - const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id); - this._reformTileGrid(T.tileGridSetForCount(grid.ids, all, n, grid.focusedId)); + // The tiles that join are the ranking's best (_tileGridRanking). + this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId)); }, /** @@ -1152,10 +1152,12 @@ Object.assign(CodemanApp.prototype, { * remembered count of tiles (the right-click menu's last pick, default 6, * at most what the window fits; owner decision 10), chosen by * `tileGridOpenSet` (constants.js): the grid this tab last had, else an open - * split's two sessions, else the open sessions in tab order, the active one - * focused; then trimmed or filled to the count (the focused one kept). A - * remembered grid comes back with its tiles in their cells, the ones the - * count adds filling its empty cells first (_openStoredTileGrid). + * split's two sessions, else the open sessions as the ranking orders them + * (_tileGridRanking: working, then needing input, then the most recent), the + * active one always among them and focused; then trimmed or filled (from the + * ranking) to the count, the focused one kept. A remembered grid comes back + * with its tiles in their cells, the ones the count adds filling its empty + * cells first (_openStoredTileGrid). */ toggleTileGrid() { this.closeTileCountMenu(); @@ -1182,9 +1184,11 @@ Object.assign(CodemanApp.prototype, { _tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) { const T = window.CodemanTileGrid; const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity)); + const ranked = this._tileGridRanking(); const set = T.tileGridOpenSet({ stored, split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null, + ranked, sessions: this.sessions, sessionOrder: this.sessionOrder, detachedIds: this.detachedSessions, @@ -1192,8 +1196,39 @@ Object.assign(CodemanApp.prototype, { limit: n, }); if (!set) return null; - const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id); - return { ...set, ids: T.tileGridSetForCount(set.ids, all, n, set.focusedId) }; + return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) }; + }, + + /** + * The open sessions the grid takes when nobody said which (the Tiles button + * with nothing stored, and every tile it fills on its own), best first: + * working (the most recently started turn first), then the ones needing + * input (the red and yellow tab alerts), then the rest by most recent + * activity, tab order breaking ties (rankTileSessions, constants.js). The + * states and stamps are the home screens' own (`_mobileOverviewState()`, + * mobile-overview.js). Detached sessions are never in it. Guarded like + * the sorted rail: without the classifier (a stale cached + * mobile-overview.js) it is plain tab order. + * + * @returns {string[]} + */ + _tileGridRanking() { + const T = window.CodemanTileGrid; + const open = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions); + if (typeof this._mobileOverviewState !== 'function' || typeof T.rankTileSessions !== 'function') { + return open.map((c) => c.id); + } + const rows = open.map(({ id }, orderIndex) => { + const session = this.sessions.get(id); + return { + id, + state: this._mobileOverviewState(session, this.pendingHooks?.get(id)), + lastActivityAt: Number(session.lastActivityAt) || 0, + lastSubmitAt: Number(session.lastSubmitAt) || 0, + orderIndex, + }; + }); + return T.rankTileSessions(rows); }, /** Alt+Shift+Arrows: a human selection of the tile in that direction. */ diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index d796b4a1..33999d0b 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -1,7 +1,8 @@ /** * @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real * app around it: constants.js + app.js + terminal-ui.js + terminal-split.js + - * tile-grid.js in one context, a small fake DOM (just what the grid touches) and a fake + * tile-grid.js + mobile-overview.js (the session state classifier the grid's + * ranking reuses) in one context, a small fake DOM (just what the grid touches) and a fake * TerminalTile that records what the grid asks of it. * * `makeGridApp()` returns an app instance with everything around the grid that @@ -295,7 +296,7 @@ const context = vm.createContext({ }); vm.runInContext( `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` + - `${read('tile-grid.js')}\n` + + `${read('tile-grid.js')}\n${read('mobile-overview.js')}\n` + 'globalThis.__CodemanApp = CodemanApp;', context ); diff --git a/test/tile-grid-open-set.test.ts b/test/tile-grid-open-set.test.ts index 35c2527c..8bb4b886 100644 --- a/test/tile-grid-open-set.test.ts +++ b/test/tile-grid-open-set.test.ts @@ -3,10 +3,11 @@ * * - `tileGridOpenSet` (constants.js, pure): what opens, in order: (a) the grid * this tab last had, if any of its sessions survive; (b) else an open - * split's two sessions; (c) else the open sessions in tab order up to the - * limit (never past the cap of 6), the active one always among them and - * focused. Detached sessions and ones that no longer exist are left out, as - * in the picker. + * split's two sessions; (c) else the open sessions in ranked order (tab + * order when no ranking is given, as here; the ranking itself is pinned in + * test/tile-grid-ranking.test.ts) up to the limit (never past the cap of + * 6), the active one always among them and focused. Detached sessions and + * ones that no longer exist are left out, as in the picker. * - The button's click and Ctrl+Shift+G are the same function * (`toggleTileGrid`); right-click (contextmenu) opens the count menu (owner * decision 10). With the grid open a pick re-forms it, the focused tile kept. @@ -59,9 +60,9 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => { }); }); - it('c: else the open sessions in tab order up to the limit, the active one focused', () => { + it('c: else the open sessions (no ranking given: tab order) up to the limit, the active one focused', () => { expect(T.tileGridOpenSet({ ...base, activeId: 't3' })).toEqual({ - source: 'tabs', + source: 'ranked', ids: order.slice(0, 6), focusedId: 't3', }); @@ -69,7 +70,7 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => { it('c: an active session past the limit still comes, with the first ones before it', () => { expect(T.tileGridOpenSet({ ...base, activeId: 't9' })).toEqual({ - source: 'tabs', + source: 'ranked', ids: ['t1', 't2', 't3', 't4', 't5', 't9'], focusedId: 't9', }); @@ -171,7 +172,7 @@ describe('opening at once, in the app', () => { app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); expect(app._tilesOwnTerminal()).toBe(true); pick(4); - // Its two first, then the open sessions in tab order. + // Its two first, then the ranking (every session quiet and unstamped here: tab order). expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']); expect(app.activeSessionId).toBe('s-b'); app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); diff --git a/test/tile-grid-ranking.test.ts b/test/tile-grid-ranking.test.ts new file mode 100644 index 00000000..e3b471f5 --- /dev/null +++ b/test/tile-grid-ranking.test.ts @@ -0,0 +1,258 @@ +/** + * @fileoverview Which sessions the tile grid takes when nobody said which + * (owner request: "prefer to load in tiles that are working and then the most + * recent, so the oldest don't get opened"). + * + * - `rankTileSessions` (constants.js, pure): WORKING first, the most recently + * started turn first (keyed off `lastSubmitAt` only: a working pane's + * last-activity stamp is always "now"); then the ones that NEED INPUT (the + * red and yellow tab alerts: `needs` and `waiting`), most recent first; then + * every other one, most recently active first. A missing stamp (0) sorts + * last in its group, tab order breaks ties, so the order never shuffles. + * - `tileGridOpenSet` case c opens the ranking's best up to the limit, the + * active session always among them and focused, detached ones never. + * - In the app (`_tileGridRanking`, the home screens' `_mobileOverviewState` + * and stamps): the Tiles button with nothing stored, a count picked in its + * menu, Ctrl/Cmd+click and an open split's fill all take the ranking; a stale + * page without the classifier falls back to tab order. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeEl, bySelector, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js'; + +type Row = { id: string; state: string; lastActivityAt?: number; lastSubmitAt?: number; orderIndex?: number }; +type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null; +type Helpers = { + rankTileSessions(rows: unknown): string[]; + tileGridOpenSet(p: Record): OpenSet; + TILE_GRID_MAX: number; +}; + +const T = windowStub.CodemanTileGrid as Helpers; +const rank = (rows: Row[]) => T.rankTileSessions(rows.map((r, orderIndex) => ({ orderIndex, ...r }))); + +describe('rankTileSessions', () => { + it('working first, then needing input (red and yellow), then everything else', () => { + expect( + rank([ + { id: 'idle', state: 'idle', lastActivityAt: 900 }, + { id: 'done', state: 'done', lastActivityAt: 800 }, + { id: 'waiting', state: 'waiting', lastActivityAt: 100 }, + { id: 'error', state: 'error', lastActivityAt: 950 }, + { id: 'working', state: 'working', lastSubmitAt: 10 }, + { id: 'needs', state: 'needs', lastActivityAt: 50 }, + ]) + ).toEqual(['working', 'waiting', 'needs', 'error', 'idle', 'done']); + }); + + it('working: the most recently started turn first', () => { + expect( + rank([ + { id: 'old', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 9_000 }, + { id: 'new', state: 'working', lastSubmitAt: 5_000, lastActivityAt: 9_000 }, + { id: 'mid', state: 'working', lastSubmitAt: 3_000, lastActivityAt: 9_000 }, + ]) + ).toEqual(['new', 'mid', 'old']); + }); + + it('working keys off lastSubmitAt only: a pane that never submitted does not take the head on its repaint stamp', () => { + expect( + rank([ + { id: 'never-submitted', state: 'working', lastSubmitAt: 0, lastActivityAt: 99_999 }, + { id: 'submitted-long-ago', state: 'working', lastSubmitAt: 1, lastActivityAt: 99_998 }, + ]) + ).toEqual(['submitted-long-ago', 'never-submitted']); + }); + + it('needing input is one group, most recent first, whichever alert it raised', () => { + expect( + rank([ + { id: 'red-old', state: 'needs', lastActivityAt: 100 }, + { id: 'yellow-new', state: 'waiting', lastActivityAt: 300 }, + { id: 'red-new', state: 'needs', lastActivityAt: 200 }, + ]) + ).toEqual(['yellow-new', 'red-new', 'red-old']); + }); + + it('the rest: most recently active first, the oldest last', () => { + expect( + rank([ + { id: 'yesterday', state: 'idle', lastActivityAt: 1_000 }, + { id: 'just-now', state: 'done', lastActivityAt: 9_000 }, + { id: 'an-hour-ago', state: 'error', lastActivityAt: 5_000 }, + ]) + ).toEqual(['just-now', 'an-hour-ago', 'yesterday']); + }); + + it('no stamp sorts last in its group; equal stamps keep tab order, so it never shuffles', () => { + const rows: Row[] = [ + { id: 'a', state: 'idle', orderIndex: 0 }, + { id: 'b', state: 'idle', lastActivityAt: 10, orderIndex: 1 }, + { id: 'c', state: 'idle', orderIndex: 2 }, + { id: 'd', state: 'idle', lastActivityAt: 10, orderIndex: 3 }, + { id: 'w', state: 'working', orderIndex: 4 }, + { id: 'v', state: 'working', lastSubmitAt: 5, orderIndex: 5 }, + ]; + const expected = ['v', 'w', 'b', 'd', 'a', 'c']; + expect(T.rankTileSessions(rows)).toEqual(expected); + expect(T.rankTileSessions([...rows].reverse())).toEqual(expected); + }); + + it('an unknown state counts as "everything else"', () => { + expect( + rank([ + { id: 'odd', state: 'mystery', lastActivityAt: 9 }, + { id: 'idle', state: 'idle', lastActivityAt: 5 }, + { id: 'busy', state: 'working', lastSubmitAt: 1 }, + ]) + ).toEqual(['busy', 'odd', 'idle']); + }); + + it('drops malformed rows and repeats, and never sorts its input in place', () => { + const rows = [ + { id: 'b', state: 'idle', lastActivityAt: 1, orderIndex: 1 }, + null, + { id: '', state: 'working' }, + { id: 'a', state: 'working', lastSubmitAt: 1, orderIndex: 0 }, + { id: 'b', state: 'idle', lastActivityAt: 1, orderIndex: 2 }, + ]; + const copy = JSON.stringify(rows); + expect(T.rankTileSessions(rows)).toEqual(['a', 'b']); + expect(JSON.stringify(rows)).toBe(copy); + expect(T.rankTileSessions(null)).toEqual([]); + }); +}); + +describe('tileGridOpenSet with a ranking (case c)', () => { + const order = ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8']; + const sessions = new Map(order.map((id) => [id, { id }])); + const ranked = ['t8', 't6', 't1', 't3', 't7', 't2', 't5', 't4']; + const base = { sessions, sessionOrder: order, ranked, limit: 4 }; + + it('opens the best of the ranking, not the first tabs, the active one focused', () => { + expect(T.tileGridOpenSet({ ...base, activeId: 't6' })).toEqual({ + source: 'ranked', + ids: ['t8', 't6', 't1', 't3'], + focusedId: 't6', + }); + }); + + it('an active session the ranking puts past the limit still comes, in the last place', () => { + expect(T.tileGridOpenSet({ ...base, activeId: 't4' })?.ids).toEqual(['t8', 't6', 't1', 't4']); + }); + + it('never a detached session or one that no longer exists', () => { + const out = T.tileGridOpenSet({ + ...base, + ranked: ['gone', ...ranked], + detachedIds: new Set(['t6']), + activeId: null, + }); + expect(out?.ids).toEqual(['t8', 't1', 't3', 't7']); + expect(out?.focusedId).toBe('t8'); + }); + + it('without a ranking it is tab order, as before', () => { + expect(T.tileGridOpenSet({ sessions, sessionOrder: order, limit: 3, activeId: null })?.ids).toEqual([ + 't1', + 't2', + 't3', + ]); + }); +}); + +describe('in the app', () => { + // Eight sessions, tab order s-1 .. s-8 (plus the harness's s-other, first and quiet). + const IDS = ['s-1', 's-2', 's-3', 's-4', 's-5', 's-6', 's-7', 's-8']; + + /** Statuses, hooks and stamps: two working, one red, one yellow, four quiet. */ + function rankedApp(): GridApp { + const app = makeGridApp(IDS); + const set = (id: string, fields: Record) => Object.assign(app.sessions.get(id), fields); + set('s-other', { status: 'idle', lastActivityAt: 1_000 }); + set('s-1', { status: 'idle', lastActivityAt: 2_000 }); // the oldest quiet one + set('s-2', { status: 'busy', lastSubmitAt: 5_000, lastActivityAt: 99_000 }); // working, older turn + set('s-3', { status: 'idle', lastActivityAt: 8_000 }); + set('s-4', { status: 'idle', lastActivityAt: 7_000 }); // red: a permission dialog + set('s-5', { status: 'stopped', lastActivityAt: 3_000 }); + set('s-6', { status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 99_000 }); // working, newest turn + set('s-7', { status: 'idle', lastActivityAt: 6_000 }); // yellow: a finished turn not seen + set('s-8', { status: 'idle', lastActivityAt: 9_500 }); + app.pendingHooks.set('s-4', new Set(['permission_prompt'])); + app.pendingHooks.set('s-7', new Set(['idle_prompt'])); + app.activeSessionId = null; + return app; + } + + beforeEach(() => { + resetGridHarness(); + bySelector.set('.terminal-wrap', new FakeEl()); + }); + + it('ranks with the home screens’ classification and stamps', () => { + expect(rankedApp()._tileGridRanking()).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-3', 's-5', 's-1', 's-other']); + }); + + it('the Tiles button with nothing stored opens working, then needing input, then the most recent; the oldest stay out', () => { + const app = rankedApp(); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-3']); + expect(app._tileGrid.ids).not.toContain('s-1'); + expect(app._tileGrid.ids).not.toContain('s-other'); + // No active session: the best ranked takes focus. + expect(app._tileGrid.focusedId).toBe('s-6'); + }); + + it('the active session always comes and takes focus, even ranked last', () => { + const app = rankedApp(); + app.activeSessionId = 's-1'; + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-1']); + expect(app._tileGrid.focusedId).toBe('s-1'); + }); + + it('the remembered count takes that many from the top; a detached session is never tiled', () => { + const app = rankedApp(); + app.detachedSessions.add('s-2'); + app._rememberTileGridCount(4); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-6', 's-4', 's-7', 's-8']); + }); + + it('a count picked in the menu fills an open grid from the ranking', () => { + const app = rankedApp(); + app.openTileGrid(['s-1', 's-5'], { focusedId: 's-5' }); + app._pickTileCount(4); + expect(app._tileGrid.cells).toEqual(['s-1', 's-5', 's-6', 's-2']); + expect(app._tileGrid.focusedId).toBe('s-5'); + }); + + it('Ctrl/Cmd+click with nothing stored: the clicked one plus the best of the ranking, the count in total', () => { + const app = rankedApp(); + app._rememberTileGridCount(4); + app.addSessionToTiles('s-5'); + expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-5']); + expect(app._tileGrid.focusedId).toBe('s-5'); + }); + + it('an open split comes first (case b), the rest from the ranking', () => { + const app = rankedApp(); + app.activeSessionId = 's-1'; + app._splitPane = {}; + app._splitSessionId = 's-5'; + app.closeSplitPane = vi.fn(() => { + app._splitPane = null; + }); + app._rememberTileGridCount(4); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-1', 's-5', 's-6', 's-2']); + expect(app._tileGrid.focusedId).toBe('s-1'); + }); + + it('a page without the classifier (a stale cached mobile-overview.js) falls back to tab order', () => { + const app = rankedApp(); + app._mobileOverviewState = undefined; + expect(app._tileGridRanking()).toEqual(['s-other', ...IDS]); + }); +});