feat(tiles): Tiles header button with a session picker, and a tile's + menu

A Tiles button beside Split in the header, opt-in through the per-device
showTileGridButton setting (read in applyHeaderVisibilitySettings; the
settings checkbox, displayKeys membership and schema exclusion follow with
persistence) and hard-gated like Split: hidden by its --hidden marker, a JS
width check with a live media listener, a @media (max-width: 1179px) backstop
and never in a solo window. While the grid is open the button closes it and
reads as pressed.

Closed, it opens a picker: a checkbox per open session in tab order (never one
popped out to its own window; one with no PTY is offered, its tile shows the
Attach overlay), names as text, preselected with the grid this tab last left,
else the active session and an open split's two. Boxes past what the window
can fit are disabled with the count shown, and Open opens the grid on the
checked sessions, focusing the active one if checked. Escape and an outside
click close it; its close method is idempotent and the global Escape handler
calls it.

A tile's + lists the open sessions not yet tiled; picking one adds it and
focuses it (a human selection). A grid that already holds what the window fits
disables the entries. "New session in this case" waits for auto-join.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 17:55:22 +02:00
parent de1b48a63f
commit b4618bb853
8 changed files with 615 additions and 2 deletions
+2
View File
@@ -1369,6 +1369,8 @@ class CodemanApp {
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeTilePicker?.();
this.closeTileAddMenu?.();
this.closeShortcutOverlay?.();
// Overlay layouts only: below 1024px the sidebar is a modal off-canvas
// drawer over the terminal, so Escape must close it. The docked desktop
+24
View File
@@ -1741,6 +1741,29 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
};
}
/**
* The sessions the Tiles picker (and a tile's + menu) offers, in tab order:
* 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.
*
* @param {Map<string, {name?: string}>} sessions
* @param {string[]} sessionOrder
* @param {{has(id: string): boolean}} [detachedIds]
* @param {{has(id: string): boolean}} [exclude] - ids not to offer (already tiled)
* @returns {Array<{id: string, label: string}>}
*/
function buildTilePickerSessions(sessions, sessionOrder, detachedIds, exclude) {
const result = [];
for (const id of sessionOrder) {
if (detachedIds?.has?.(id) || exclude?.has?.(id)) continue;
const session = sessions.get(id);
if (!session) continue;
result.push({ id, label: session.name || 'Session' });
}
return result;
}
/**
* Which tile takes focus when `id` leaves the grid: the next one in grid
* order, else the previous one, else null.
@@ -2050,6 +2073,7 @@ if (typeof window !== 'undefined') {
computeTileLayout,
tileGridCapacity,
sanitizeTileGridState,
buildTilePickerSessions,
tileNeighbor,
tileInDirection,
cycleTile,
+1
View File
@@ -192,6 +192,7 @@
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.openTilePicker(event)" title="Tiles: show several sessions side by side" aria-label="Tiles: show several sessions side by side" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
+4
View File
@@ -3632,6 +3632,10 @@ Object.assign(CodemanApp.prototype, {
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
this._applySplitButtonVisibility?.(showSplitButton);
// Tiles button: same gate and backstop as Split (tile-grid.js).
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
this._applyTileGridButtonVisibility?.(showTileGridButton);
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
// from the mobile-header-buttons-policy guard.
+95 -1
View File
@@ -2510,6 +2510,19 @@ html[data-tab-orientation='vertical'].tab-rail-compact
display: none !important;
}
/* Tile grid header button: hidden by default (opt-in, `showTileGridButton`),
and hard-gated to desktop widths like Split (the backstop below). */
.btn-tile-grid--hidden {
display: none !important;
}
/* While the grid is open the button's click closes it (aria-pressed too). */
.btn-icon-header.btn-tile-grid.tiles-open,
.btn-icon-header.btn-tile-grid.tiles-open:hover {
background: var(--accent);
color: var(--accent-ink);
}
/* The tile grid is open (tile-grid.js): the split cannot open beside it. */
.btn-split.btn-split--blocked {
opacity: 0.4;
@@ -2537,7 +2550,8 @@ html[data-tab-orientation='vertical'].tab-rail-compact
HOME_SESSIONS_MIN_WIDTH (home-sessions.js) / SPLIT_PANE_MIN_WIDTH
(constants.js) — keep the three in sync. */
@media (max-width: 1179px) {
.btn-icon-header.btn-split {
.btn-icon-header.btn-split,
.btn-icon-header.btn-tile-grid {
display: none !important;
}
}
@@ -2853,6 +2867,7 @@ body.solo-mode .header-tokens,
body.solo-mode .btn-notifications,
body.solo-mode .btn-multimonitor,
body.solo-mode .btn-split,
body.solo-mode .btn-tile-grid,
body.solo-mode .header-plan-usage,
/* A solo window shows ONE session and has no tab strip to put restored ones in,
so offering to rebuild a list of them there is an offer it cannot show the
@@ -19575,6 +19590,85 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
pointer-events: none;
}
/* Tiles picker (tile-grid.js openTilePicker): a checkbox per open session,
positioned `fixed` under the Tiles button; same layer as the split picker. */
.tile-picker-menu {
position: fixed;
z-index: 1000;
min-width: 240px;
max-width: 340px;
max-height: 60vh;
display: flex;
flex-direction: column;
padding: 6px;
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
font-size: 13px;
}
.tile-picker-list {
overflow-y: auto;
min-height: 0;
}
.tile-picker-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
}
.tile-picker-item:hover {
background: color-mix(in srgb, var(--text-muted, #888) 14%, transparent);
}
.tile-picker-item span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.tile-picker-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 4px 2px;
border-top: 1px solid var(--border-color, #333);
margin-top: 4px;
}
.tile-picker-hint,
.tile-picker-empty,
.tile-add-empty {
color: var(--text-muted);
font-size: 12px;
}
.tile-picker-empty,
.tile-add-empty {
padding: 6px 8px;
}
.tile-picker-open {
padding: 4px 12px;
font: inherit;
color: var(--accent-ink, #fff);
background: var(--accent, #4a9eff);
border: none;
border-radius: 4px;
cursor: pointer;
}
.tile-picker-open:disabled {
opacity: 0.5;
cursor: default;
}
/* Split-picker menu: a small popover listing sessions to split with, appended
to document.body and positioned `fixed` by openSplitPicker() (JS sets
top/right against the Split header button's own rect). z-index above the
+240
View File
@@ -202,6 +202,7 @@ Object.assign(CodemanApp.prototype, {
this._selectTiledSession(focus, { auto });
this._updateConnectionIndicator?.();
this._updateSplitButtonForTiles();
this._updateTileGridButtonState();
return true;
},
@@ -261,11 +262,249 @@ Object.assign(CodemanApp.prototype, {
}
this._updateConnectionIndicator?.();
this._updateSplitButtonForTiles();
this._updateTileGridButtonState();
this.closeTileAddMenu();
// The tabs drop their .in-tiles marker.
this.renderSessionTabs?.();
if (reselect) this._selectAfterTileGrid(focusedId);
},
/**
* The header Tiles button: shown when its per-device setting is on AND the
* window is desktop-wide (a JS check plus a live media listener, the same
* pair as the Split button; the CSS `@media (max-width: 1179px)` rule is the
* backstop that hides it even if this never runs).
*/
_applyTileGridButtonVisibility(enabled) {
this._tileGridButtonSettingEnabled = !!enabled;
const btn = document.querySelector('.btn-tile-grid');
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
btn?.classList.toggle('btn-tile-grid--hidden', !enabled || !wide || !!this.isSoloWindow);
if (!this._tileGridButtonWidthListener && window.matchMedia) {
this._tileGridButtonWidthListener = true;
const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
mq.addEventListener('change', () => this._applyTileGridButtonVisibility(this._tileGridButtonSettingEnabled));
}
},
// Open grid: the button's click closes it, and says so.
_updateTileGridButtonState() {
const btn = document.querySelector('.btn-tile-grid');
if (!btn) return;
const open = this._tilesOwnTerminal();
btn.classList.toggle('tiles-open', open);
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
const title = open ? 'Tiles: back to a single session' : 'Tiles: show several sessions side by side';
btn.title = title;
btn.setAttribute('aria-label', title);
},
/** How many tiles the terminal area can hold right now (the grid section, or the single view it would replace). */
_tileGridCapacityNow() {
const el = this._tilesOwnTerminal() ? this._tileGridSection() : document.querySelector('.terminal-wrap');
const rect = el?.getBoundingClientRect?.() || { width: 0, height: 0 };
return window.CodemanTileGrid.tileGridCapacity({
width: rect.width || window.innerWidth,
height: rect.height || window.innerHeight,
});
},
/**
* The Tiles button: with the grid open it closes it (back to the single view
* of the focused session); otherwise it opens a picker with a checkbox per
* open session, in tab order, preselected with the grid this tab last left
* (else the active session and an open split's two), and an Open button.
* Boxes past what the window can fit are disabled.
*/
openTilePicker(event) {
// As the split picker: the opening click must not reach the outside-click
// listener this call installs.
event?.stopPropagation?.();
if (this._tilesOwnTerminal()) {
this.closeTileGrid({ keepStored: true, reselect: true });
return;
}
if (this._tilePicker) {
this.closeTilePicker();
return;
}
if (!this.canOpenTileGrid()) return;
const T = window.CodemanTileGrid;
const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX));
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
const remembered = (this._tileGridRemembered?.ids || []).filter((id) => candidates.some((c) => c.id === id));
const seed = remembered.length
? remembered
: [this.activeSessionId, this._splitPane ? this._splitSessionId : null].filter(Boolean);
const checked = new Set(seed.slice(0, capacity));
const menu = document.createElement('div');
menu.id = 'tilePickerMenu';
menu.className = 'tile-picker-menu';
menu.setAttribute('role', 'dialog');
menu.setAttribute('aria-label', 'Show sessions as tiles');
const list = document.createElement('div');
list.className = 'tile-picker-list';
const boxes = [];
for (const c of candidates) {
const row = document.createElement('label');
row.className = 'tile-picker-item';
const box = document.createElement('input');
box.type = 'checkbox';
box.value = c.id;
box.checked = checked.has(c.id);
const name = document.createElement('span');
// A session literally named like a UI string must not be translated.
name.setAttribute('data-i18n-skip', '');
name.textContent = c.label;
row.append(box, name);
list.appendChild(row);
boxes.push(box);
}
const footer = document.createElement('div');
footer.className = 'tile-picker-footer';
const hint = document.createElement('span');
hint.className = 'tile-picker-hint';
hint.textContent = `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`;
const open = document.createElement('button');
open.type = 'button';
open.className = 'tile-picker-open';
open.textContent = 'Open tiles';
footer.append(hint, open);
if (candidates.length === 0) {
const empty = document.createElement('div');
empty.className = 'tile-picker-empty';
empty.textContent = 'No sessions to show as tiles';
menu.appendChild(empty);
} else {
menu.append(list, footer);
}
const sync = () => {
const count = boxes.filter((b) => b.checked).length;
for (const b of boxes) {
b.disabled = !b.checked && count >= capacity;
b.title = b.disabled ? `This window fits ${capacity} tiles` : '';
}
open.disabled = count === 0;
};
for (const b of boxes) b.addEventListener('change', sync);
sync();
open.addEventListener('click', () => {
const ids = boxes.filter((b) => b.checked).map((b) => b.value);
this.closeTilePicker();
if (ids.length === 0) return;
this.openTileGrid(ids, { focusedId: ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0] });
});
document.body.appendChild(menu);
const btn = document.querySelector('.btn-tile-grid');
if (btn?.getBoundingClientRect) {
const rect = btn.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.top = `${rect.bottom + 4}px`;
menu.style.right = `${window.innerWidth - rect.right}px`;
}
const onOutside = (e) => {
if (menu.contains?.(e.target) || e.target?.closest?.('.btn-tile-grid')) return;
this.closeTilePicker();
};
const onKey = (e) => {
if (e.key === 'Escape') this.closeTilePicker();
};
this._tilePicker = { menu, onOutside, onKey };
// Deferred a tick so the opening click (still bubbling) does not close it.
setTimeout(() => {
if (this._tilePicker?.menu === menu) document.addEventListener('click', onOutside);
}, 0);
document.addEventListener('keydown', onKey);
(boxes.find((b) => !b.disabled) || open).focus?.();
},
/** Idempotent: the global Escape handler calls it whether or not the picker is open. */
closeTilePicker() {
const picker = this._tilePicker;
if (!picker) return;
this._tilePicker = null;
document.removeEventListener('click', picker.onOutside);
document.removeEventListener('keydown', picker.onKey);
picker.menu.remove();
},
/**
* A tile's +: the open sessions not yet tiled, in tab order; picking one
* adds it to the grid and focuses it (a human selection). Disabled once the
* grid holds what the window can fit.
*/
openTileAddMenu(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
const grid = this._tileGrid;
if (!grid?.open) return;
const trigger = event?.currentTarget || null;
if (this._tileAddMenu && this._tileAddMenu.trigger === trigger) {
this.closeTileAddMenu();
return;
}
this.closeTileAddMenu();
const T = window.CodemanTileGrid;
const capacity = Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX);
const full = grid.ids.length >= Math.max(capacity, 1);
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions, grid.tiles);
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu tile-add-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Add a session to the grid');
if (candidates.length === 0) {
const empty = document.createElement('div');
empty.className = 'tile-add-empty';
empty.textContent = 'Every open session is already tiled';
menu.appendChild(empty);
}
for (const c of candidates) {
const item = document.createElement('button');
item.type = 'button';
item.setAttribute('role', 'menuitem');
item.setAttribute('data-i18n-skip', '');
item.textContent = c.label;
item.disabled = full;
if (full) item.title = `The grid already holds what this window fits (${capacity})`;
item.addEventListener('click', () => {
this.closeTileAddMenu();
if (this.addTile(c.id)) this.selectSession(c.id);
});
menu.appendChild(item);
}
document.body.appendChild(menu);
if (trigger?.getBoundingClientRect) {
const rect = trigger.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.top = `${rect.bottom + 4}px`;
menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
}
const onOutside = (e) => {
if (menu.contains?.(e.target) || (trigger && trigger.contains?.(e.target))) return;
this.closeTileAddMenu();
};
const onKey = (e) => {
if (e.key === 'Escape') this.closeTileAddMenu();
};
this._tileAddMenu = { menu, trigger, onOutside, onKey };
document.addEventListener('pointerdown', onOutside, true);
document.addEventListener('keydown', onKey);
menu.querySelector?.('button:not([disabled])')?.focus?.();
},
/** Idempotent, like closeTilePicker. */
closeTileAddMenu() {
const m = this._tileAddMenu;
if (!m) return;
this._tileAddMenu = null;
document.removeEventListener('pointerdown', m.onOutside, true);
document.removeEventListener('keydown', m.onKey);
m.menu.remove();
},
// The Split button cannot act while the grid is open (openSplitPicker and
// openSplitPane refuse), so it says so: aria-disabled plus a title, the same
// refusal the split already gives for web tabs and the welcome screen.
@@ -645,6 +884,7 @@ Object.assign(CodemanApp.prototype, {
actions.append(
button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)),
zoomBtn,
button('tile-add', 'Add a session to the grid', '+', (e) => this.openTileAddMenu(e)),
// Removes the tile ONLY: the session keeps running. Killing it stays
// behind the menu's Close session and its confirm.
button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () =>
+8 -1
View File
@@ -51,6 +51,11 @@ export class FakeEl {
title = '';
hidden = false;
disabled = false;
checked = false;
contains(other: FakeEl | null): boolean {
for (let n: FakeEl | null = other; n; n = n.parentElement) if (n === this) return true;
return false;
}
textContent = '';
value = '';
type = '';
@@ -154,6 +159,7 @@ export const body = new FakeEl();
export const fetchSpy = vi.fn(async (..._args: unknown[]) => ({ ok: true, json: async () => ({}) }));
/** `document.addEventListener`, so a test can find a listener the app installed. */
export const documentAddEventListener = vi.fn();
export const documentRemoveEventListener = vi.fn();
export const localStore = new Map<string, string>();
/** The clock behind `performance.now` inside the context; tests move it with advanceClock(). */
let clock = 100_000;
@@ -198,12 +204,13 @@ const context = vm.createContext({
history: { replaceState: vi.fn(), state: null },
document: {
addEventListener: documentAddEventListener,
removeEventListener: documentRemoveEventListener,
documentElement: { dataset: {} },
createElement: () => new FakeEl(),
getElementById: (id: string) => (id === 'tileGrid' ? section : null),
body,
querySelector: (sel: string) =>
sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : (bySelector.get(sel) ?? null),
bySelector.get(sel) ?? (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
querySelectorAll: () => [],
},
localStorage: {
+241
View File
@@ -0,0 +1,241 @@
/**
* @fileoverview The header Tiles button, its picker, and a tile's + menu.
*
* - The button is opt-in (`showTileGridButton`, hidden by its `--hidden` marker
* class) and hard-gated to desktop widths like Split: a JS width check plus a
* CSS `@media (max-width: 1179px)` backstop, and never in a solo window.
* With the grid open it closes it (`aria-pressed`).
* - The picker has a checkbox per open session, in tab order, never a session
* popped out to its own window; names are text, never markup. It is
* preselected with the grid this tab last left, else the active session and
* an open split's two. Boxes past what the window can fit are disabled; Open
* opens the grid on the checked ones, focusing the active session if checked.
* - Escape closes it, and its close method is idempotent (the global Escape
* handler calls every close method).
* - A tile's + lists the open sessions not yet tiled; picking one adds and
* focuses it; a grid that holds what the window fits disables them.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
body,
bySelector,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
function makeButton() {
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden';
bySelector.set('.btn-tile-grid', btn);
return btn;
}
const picker = () => body.children.find((c) => c.id === 'tilePickerMenu') ?? null;
const rows = () => picker()!.children[0].children;
const boxOf = (id: string) => rows().find((r) => r.children[0].value === id)!.children[0];
const openButton = () => picker()!.children[1].children[1];
const check = (id: string, on = true) => {
const box = boxOf(id);
box.checked = on;
box.dispatch('change');
};
let wrapRect = { width: 2400, height: 1200 };
beforeEach(() => {
resetGridHarness();
wrapRect = { width: 2400, height: 1200 };
const wrap = new FakeEl();
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
bySelector.set('.terminal-wrap', wrap);
});
afterEach(() => {
vi.useRealTimers();
});
describe('the Tiles button', () => {
it('shows only when its setting is on and the window is desktop-wide', () => {
const app = makeGridApp(IDS);
const btn = makeButton();
app._applyTileGridButtonVisibility(false);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(false);
windowStub.innerWidth = 1100;
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('never in a solo window', () => {
const app = makeGridApp(IDS);
app.isSoloWindow = true;
const btn = makeButton();
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('has the CSS backstops: the hidden marker, the 1179px media query, solo mode', () => {
expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/);
expect(css).toMatch(
/@media \(max-width: 1179px\)\s*\{[^}]*\.btn-icon-header\.btn-tile-grid[^{]*\{\s*display: none !important;/
);
expect(css).toMatch(/body\.solo-mode \.btn-tile-grid,/);
});
it('with the grid open, a click closes it, and the button says so meanwhile', () => {
const app = makeGridApp(IDS);
const btn = makeButton();
app.selectSession = vi.fn();
app.openTileGrid(IDS);
expect(btn.getAttribute('aria-pressed')).toBe('true');
expect(btn.classList.contains('tiles-open')).toBe(true);
app.openTilePicker({ stopPropagation: vi.fn() });
expect(app._tilesOwnTerminal()).toBe(false);
expect(btn.getAttribute('aria-pressed')).toBe('false');
expect(picker()).toBeNull();
});
});
describe('the picker', () => {
it('lists open sessions in tab order, never a detached one, names as text', () => {
const app = makeGridApp(IDS);
app.sessions.get('s-b').name = '<i>b</i>';
app.detachedSessions.add('s-c');
app.sessions.get('s-a').pid = null; // offered: its tile shows the Attach overlay
app.openTilePicker({ stopPropagation: vi.fn() });
const labels = rows().map((r) => r.children[1]);
expect(rows().map((r) => r.children[0].value)).toEqual(['s-other', 's-a', 's-b']);
expect(labels[2].textContent).toBe('<i>b</i>');
expect(labels[2].children).toHaveLength(0);
expect(labels[2].getAttribute('data-i18n-skip')).toBe('');
});
it('preselects the active session', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-b';
app.openTilePicker({ stopPropagation: vi.fn() });
expect(
rows()
.filter((r) => r.children[0].checked)
.map((r) => r.children[0].value)
).toEqual(['s-b']);
});
it('preselects the grid this tab last left', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-a', 's-c']);
app.closeTileGrid({ reselect: false });
app.openTilePicker({ stopPropagation: vi.fn() });
expect(
rows()
.filter((r) => r.children[0].checked)
.map((r) => r.children[0].value)
).toEqual(['s-a', 's-c']);
});
it('disables boxes past what the window fits, and says how many', () => {
const app = makeGridApp(IDS);
wrapRect = { width: 1000, height: 400 }; // fits 2 (2x1 of 500x400)
app.openTilePicker({ stopPropagation: vi.fn() });
expect(picker()!.children[1].children[0].textContent).toBe('This window fits 2 tiles');
check('s-b');
expect(boxOf('s-c').disabled).toBe(true);
expect(boxOf('s-other').disabled).toBe(true);
check('s-b', false);
expect(boxOf('s-c').disabled).toBe(false);
});
it('Open opens the grid on the checked sessions, in tab order, focusing the active one', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-b';
app.openTilePicker({ stopPropagation: vi.fn() });
check('s-a');
check('s-c');
openButton().dispatch('click');
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(picker()).toBeNull();
});
it('Open is disabled with nothing checked', () => {
const app = makeGridApp(IDS);
app.openTilePicker({ stopPropagation: vi.fn() });
check('s-a', false);
expect(openButton().disabled).toBe(true);
});
it('Escape closes it, and closing twice is harmless (the global Escape calls every close method)', () => {
const app = makeGridApp(IDS);
app.openTilePicker({ stopPropagation: vi.fn() });
const onKey = app._tilePicker.onKey;
onKey({ key: 'Escape' });
expect(picker()).toBeNull();
expect(() => app.closeTilePicker()).not.toThrow();
});
it('the global Escape handler closes the picker and the + menu', () => {
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const escape = src.slice(src.indexOf("if (e.key === 'Escape') {"), src.indexOf('Option/Alt session navigation'));
expect(escape).toContain('this.closeTilePicker?.();');
expect(escape).toContain('this.closeTileAddMenu?.();');
});
it('refuses in a narrow window', () => {
const app = makeGridApp(IDS);
windowStub.innerWidth = 1100;
app.openTilePicker({ stopPropagation: vi.fn() });
expect(picker()).toBeNull();
});
});
describe("a tile's + menu", () => {
const addMenu = () => body.children.find((c) => c.className.includes('tile-add-menu')) ?? null;
const addButtonOf = (id: string) =>
(section.children.find((el) => el.dataset.sessionId === id) as FakeEl).children[0].children[2].children.find((b) =>
b.className.includes('tile-add')
) as FakeEl;
it('lists the open sessions not yet tiled; picking one adds and focuses it', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b']);
app.markIdleAlertSeen.mockClear();
addButtonOf('s-a').dispatch('click', { stopPropagation: vi.fn(), currentTarget: null });
const items = addMenu()!.children;
expect(items.map((i) => i.textContent)).toEqual(['s-other', 's-c']);
items[1].dispatch('click');
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
expect(addMenu()).toBeNull();
});
it('a grid holding what the window fits disables the entries', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b']);
const sectionRect = section.getBoundingClientRect;
section.getBoundingClientRect = () => ({ width: 1000, height: 400, top: 0, left: 0, right: 1000, bottom: 400 });
app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null });
expect(addMenu()!.children.every((i) => i.disabled)).toBe(true);
section.getBoundingClientRect = sectionRect;
});
it('closing the grid closes it', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-a']);
app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null });
app.closeTileGrid({ reselect: false });
expect(addMenu()).toBeNull();
});
});