mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
feat(tiles): open and fill tiles by work, then input, then recency
The Tiles button with no stored grid opened the first tabs, so the oldest sessions got tiles while the busy ones were left out. It now ranks the open sessions: working first (the most recently started turn first, keyed off lastSubmitAt only, since a working pane's activity stamp is always "now"), then the ones needing input (the red and yellow tab alerts), then everything else by most recent activity, tab order breaking ties. The ranking is pure (rankTileSessions in constants.js) and reuses the home screens' classification (_mobileOverviewState) and stamps. It feeds the toggle's fresh set, a count picked in the menu, Ctrl/Cmd+click and an open split's fill. The active session is still always included and focused, detached sessions are never tiled, and a page without the classifier falls back to tab order. The vm harness now loads mobile-overview.js so the app tests exercise the real classifier. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
55cd59a346
commit
950450b137
5 files changed
+403
-30
No files matched your search
+90
-12
@@ -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<string, object>, sessionOrder: string[],
|
||||
* split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, 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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>) => 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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user