feat(tiles): the Tiles button opens the grid at once; the picker is on right-click

Owner decision 8 ("when I hit the tiles button, open the tiles already!").
A click with the grid closed now opens it straight away, and Ctrl+Shift+G
runs the same function (toggleTileGrid), so the two cannot drift. What
opens comes from one pure helper, tileGridOpenSet (constants.js):
  a. the grid this tab last had, if any of its sessions survive, opened
     exactly (an open split closes and its sessions do not join);
  b. else an open split's two sessions, Pane A focused;
  c. else the open sessions in tab order (the picker's list: no detached
     ones), up to what the grid takes here (the cap of 6, fewer when the
     window fits fewer), the active session always among them and focused.
A click with the grid open still closes it.

The picker moved to right-click (oncontextmenu, browser menu suppressed).
With the grid open it is preselected with the current tiles, and Open
replaces them. Ctrl/Cmd+click on a tab with the grid closed opens the
toggle's set plus that session. The button's title, the Help modal and
the wiki say right-click chooses which sessions.

Docs: decision 8 and an as-built entry in the spec (Entry points too),
CLAUDE.md, the invariants (#tile-grid, Opening), the wiki's Tile Grid and
Keyboard Shortcuts pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 23:23:55 +02:00
parent d2e72143e8
commit b7fafb1c16
14 changed files with 322 additions and 45 deletions
+1 -1
View File
@@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid)
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid)
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
+2
View File
@@ -807,6 +807,8 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched.
⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does).
⚠️ **Chrome.** The header is a fixed 24px sibling of the body, refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY.
⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session.
+19 -3
View File
@@ -29,6 +29,14 @@ or settled a question the spec left open. The invariants as built are in
B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps
its last frame.
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
- **The Tiles button opens the grid at once** (owner decision 8): a click (and
`Ctrl+Shift+G`, the same `toggleTileGrid`) opens `tileGridOpenSet` (constants.js): the
grid this tab last had, else an open split's two sessions, else the open sessions in tab
order up to what the grid takes here (the cap, or fewer when the window fits fewer), the
active one always included and focused. A remembered grid wins over an open split: the
split closes and its sessions do not join (the owner's order, read literally). Right-click
opens the picker; with the grid open it shows the current tiles and Open replaces them.
Ctrl/Cmd+click on a tab with the grid closed opens the same set plus that session.
- **The grid holds at most 6 tiles** (owner decision 7). `TILE_GRID_MAX` in constants.js is
the one cap every limit reads; the layout table keeps 7 to 9 (`TILE_LAYOUT_MAX`), unreachable,
so going back to nine is that one line. A stored grid with more ids comes back as its first
@@ -143,9 +151,10 @@ work also fixes gaps the split pane has today.
### Entry points
- **Header Tiles button** (its own button, beside Split). Opens a picker with
checkboxes over open sessions, ordered like the tab strip. When the grid is
open, the button toggles it closed.
- **Header Tiles button** (its own button, beside Split). As built (decision 8)
a click opens the grid at once, the same as the toggle shortcut; the picker
with checkboxes over open sessions, ordered like the tab strip, is on
right-click. When the grid is open, a click closes it.
- **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if
closed).
- **Drag a tab** from the strip onto a tile to replace it, or onto an empty
@@ -879,6 +888,13 @@ exits green. Use the browser runner for those files and read the file count.
real hardware. The cap is one constant (`TILE_GRID_MAX`), the layout table
keeps 7 to 9 working but unreachable, and the user-facing texts say "at most
6 tiles" when the cap, not the window, is what limits the grid.
8. **The Tiles button opens the grid directly.** Decided by the owner ("when I
hit the tiles button, open the tiles already!"): a click opens the grid with
no picker in the way, choosing the grid this tab last had, else an open
split's two sessions, else the open sessions in tab order up to the cap with
the active one focused; `Ctrl+Shift+G` runs the same function. The picker
is on right-click of the button (its title says so, as do the wiki and the
Help modal).
## Code anchors
+1
View File
@@ -47,6 +47,7 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
| Right-click the Tiles button | Choose which sessions to show as tiles. |
While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the
terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by
+8 -6
View File
@@ -15,12 +15,14 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`.
## Opening a grid
- **Tiles button**: with the grid closed it opens a picker, a checkbox per open session in
tab order. It starts with the grid you last had (or the session you are on), says how many
tiles this window fits, and greys out the rest. **Open tiles** shows them. With the grid
open, the same button closes it.
- **`Ctrl+Shift+G`**: toggles the grid. Opening brings back the grid you last had, else an
open split as two tiles, else the session you are on.
- **Tiles button**: one click shows the tiles straight away. You get the grid you last
had; if there is none, an open split as two tiles; otherwise your open sessions in tab
order, up to six (fewer if the window is too small), with the session you are on
focused. With the grid open, the same button closes it.
- **Right-click the Tiles button** to choose which sessions: a checkbox per open session in
tab order, starting with the tiles you have (or had), greying out the rest once the grid
is full. **Open tiles** shows them, replacing what the grid showed.
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it,
opening the grid if it was closed. On macOS use `Cmd`: `Ctrl`+click there opens the tab's
rename instead.
+35
View File
@@ -1802,6 +1802,40 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds, exclude) {
return result;
}
/**
* 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, the picker's list (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).
* 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[],
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
* @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
*/
function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
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] };
}
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
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] };
}
/**
* Which tile takes focus when `id` leaves the grid: the next one in grid
* order, else the previous one, else null.
@@ -2116,6 +2150,7 @@ if (typeof window !== 'undefined') {
tileNeighbor,
tileInDirection,
cycleTile,
tileGridOpenSet,
TILE_GRID_MAX,
TILE_LAYOUT_MAX,
TILE_MIN_W,
+2 -1
View File
@@ -192,7 +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-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTilePicker(event)" title="Tiles: show several sessions side by side (right-click to choose which)" aria-label="Tiles: show several sessions side by side (right-click to choose which)" 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;">
@@ -840,6 +840,7 @@
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose Which Sessions to Tile</div>
</div>
</section>
<section class="shortcut-section">
+60 -27
View File
@@ -161,14 +161,16 @@ Object.assign(CodemanApp.prototype, {
* Opens the grid on `ids` (unknown, detached and duplicate ids are skipped;
* at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it
* adds what is missing and moves focus. `auto: false` makes the focus a human
* selection (it acknowledges that session's idle alert).
* selection (it acknowledges that session's idle alert). An open split
* closes (the two are never open together); `mergeSplit` (default) makes its
* two sessions the first tiles, false opens exactly `ids` (a stored grid).
*
* Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while
* its snapshot of the session it shows is still right, and closes its socket.
*
* @returns {boolean} whether the grid is open afterwards
*/
openTileGrid(ids, { focusedId = null, auto = true } = {}) {
openTileGrid(ids, { focusedId = null, auto = true, mergeSplit = true } = {}) {
if (!this.canOpenTileGrid()) return false;
const grid = (this._tileGrid ||= new TileGridModel());
const max = window.CodemanTileGrid.TILE_GRID_MAX;
@@ -179,8 +181,10 @@ Object.assign(CodemanApp.prototype, {
if (this._splitPane) {
const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
this.closeSplitPane({ skipPrimaryResize: true });
requested = [...seed, ...requested];
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
if (mergeSplit) {
requested = [...seed, ...requested];
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
}
}
const wanted = [];
for (const id of requested) {
@@ -323,7 +327,9 @@ Object.assign(CodemanApp.prototype, {
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';
const title = open
? 'Tiles: back to a single session (right-click to choose which sessions)'
: 'Tiles: show several sessions side by side (right-click to choose which)';
btn.title = title;
btn.setAttribute('aria-label', title);
},
@@ -358,20 +364,18 @@ Object.assign(CodemanApp.prototype, {
},
/**
* 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
* Right-click on the Tiles button (its click opens the grid at once, see
* toggleTileGrid): a picker with a checkbox per open session, in tab order,
* preselected with the tiles shown now, else 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.
* Boxes past what the grid takes are disabled. With the grid open, Open
* replaces its set.
*/
openTilePicker(event) {
// As the split picker: the opening click must not reach the outside-click
// As the split picker: the opening event must not reach the outside-click
// listener this call installs.
event?.preventDefault?.();
event?.stopPropagation?.();
if (this._tilesOwnTerminal()) {
this.closeTileGrid({ keepStored: true, reselect: true });
return;
}
if (this._tilePicker) {
this.closeTilePicker();
return;
@@ -381,7 +385,10 @@ Object.assign(CodemanApp.prototype, {
const limit = this._tileGridLimit();
const capacity = limit.capacity;
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
const remembered = (this._readStoredTileGrid()?.ids || []).filter((id) => candidates.some((c) => c.id === id));
const gridOpen = this._tilesOwnTerminal();
const remembered = (gridOpen ? this._tileGrid.ids : this._readStoredTileGrid()?.ids || []).filter((id) =>
candidates.some((c) => c.id === id)
);
const seed = remembered.length
? remembered
: [this.activeSessionId, this._splitPane ? this._splitSessionId : null].filter(Boolean);
@@ -443,7 +450,15 @@ Object.assign(CodemanApp.prototype, {
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] });
const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
if (this._tilesOwnTerminal()) {
// A new set for the open grid, as "Open group as tiles" does it: the
// parked terminal still holds what it showed before the grid, and
// re-parking must not snapshot it.
this.closeTileGrid({ keepStored: false, reselect: false });
this.activeSessionId = null;
}
this.openTileGrid(ids, { focusedId: focus });
});
document.body.appendChild(menu);
@@ -666,23 +681,41 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Opens the grid, or closes it to the single view of the focused session.
* Opening brings back the grid this tab last left (decision 1: one step back
* after a selection outside it), else an open split as two tiles, else the
* active session as one tile.
* The Tiles button's click and Ctrl+Shift+G, one function so the two never
* drift (owner decision 8): opens the grid at once, no picker in the way, or
* closes it to the single view of the focused session. What opens is
* `tileGridOpenSet` (constants.js): the grid this tab last had, else an open
* split's two sessions, else the open sessions in tab order up to what the
* grid takes here, the active one focused. The picker is a right-click away.
*/
toggleTileGrid() {
this.closeTilePicker();
if (this._tilesOwnTerminal()) {
this.closeTileGrid({ keepStored: true, reselect: true });
return;
}
if (!this.canOpenTileGrid()) return;
const remembered = this._readStoredTileGrid();
if (remembered?.ids.length) {
this._openStoredTileGrid(remembered);
const stored = this._readStoredTileGrid();
const set = this._tileGridOpenSet(stored);
if (!set) {
this.showToast?.('No sessions to show as tiles', 'info');
return;
}
if (this.activeSessionId) this.openTileGrid([this.activeSessionId], { focusedId: this.activeSessionId });
if (set.source === 'stored') this._openStoredTileGrid(stored);
else this.openTileGrid(set.ids, { focusedId: set.focusedId });
},
/** What the toggle would open now (see toggleTileGrid); `stored` saves a second read. */
_tileGridOpenSet(stored = this._readStoredTileGrid()) {
return window.CodemanTileGrid.tileGridOpenSet({
stored,
split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
sessions: this.sessions,
sessionOrder: this.sessionOrder,
detachedIds: this.detachedSessions,
activeId: this.activeSessionId,
limit: this._tileGridLimit().capacity,
});
},
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
@@ -932,8 +965,7 @@ Object.assign(CodemanApp.prototype, {
this.selectSession(sessionId);
return true;
}
const remembered = this._readStoredTileGrid()?.ids || [];
const base = remembered.length ? remembered : [this.activeSessionId].filter(Boolean);
const base = this._tileGridOpenSet()?.ids || [];
const ids = [...base.filter((id) => id !== sessionId).slice(0, capacity - 1), sessionId];
this.openTileGrid(ids, { focusedId: sessionId, auto: false });
return true;
@@ -1672,7 +1704,8 @@ Object.assign(CodemanApp.prototype, {
*/
_openStoredTileGrid(stored) {
const focus = stored.zoomed || stored.focused;
if (!this.openTileGrid(stored.ids, { focusedId: focus, auto: true })) return false;
// Exactly the stored set: an open split closes without joining it (decision 8, case a).
if (!this.openTileGrid(stored.ids, { focusedId: focus, auto: true, mergeSplit: false })) return false;
const grid = this._tileGrid;
// openTileGrid laid the grid out with equal tracks. The stored ones go back
// on; _applyTileLayout drops them again if they do not match the column or
+1
View File
@@ -58,6 +58,7 @@ describe('help modal shortcuts', () => {
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Arrows'], 'Focus Tile Left / Right / Up / Down');
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Enter'], 'Zoom Focused Tile');
expectShortcut(helpModal, ['Ctrl/Cmd', 'Click'], 'Add the Session to the Tile Grid');
expectShortcut(helpModal, ['Right-click'], 'Choose Which Sessions to Tile');
});
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
+3 -2
View File
@@ -125,11 +125,12 @@ describe('dragging a tab onto an empty slot', () => {
describe('Ctrl/Cmd+click on a tab', () => {
const click = (mods: Record<string, boolean>) => ({ preventDefault: vi.fn(), ...mods });
it('with the grid closed: opens it with the active session and the clicked one, focusing it', () => {
it('with the grid closed: opens what the Tiles toggle would, plus the clicked one, focusing it', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-a';
// Nothing remembered, no split: the open sessions in tab order (the clicked one last).
app.handleSessionTabClick(click({ ctrlKey: true }), 's-c');
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
});
+167
View File
@@ -0,0 +1,167 @@
/**
* @fileoverview The Tiles button opens the grid at once (owner decision 8).
*
* - `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.
* - The button's click and Ctrl+Shift+G are the same function
* (`toggleTileGrid`); right-click (contextmenu) opens the picker. With the
* grid open the picker shows the current tiles, and Open replaces them.
*
* Pure helper via `vm`, the app via the shared harness (test/mocks/tile-grid-vm.ts).
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeEl, body, bySelector, makeGridApp, resetGridHarness } from './mocks/tile-grid-vm.js';
type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null;
type Helpers = { tileGridOpenSet(p: Record<string, unknown>): OpenSet; TILE_GRID_MAX: number };
function loadHelpers(): Helpers {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'), context);
return (context.window as { CodemanTileGrid: Helpers }).CodemanTileGrid;
}
const T = loadHelpers();
const INDEX_HTML = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
const order = Array.from({ length: 9 }, (_, i) => `t${i + 1}`);
const sessions = new Map(order.map((id) => [id, { id, name: id }]));
const base = { sessions, sessionOrder: order, limit: 6 };
describe('tileGridOpenSet (what the Tiles button opens)', () => {
it('a: the grid this tab last had, its focus (or its zoom) included', () => {
const stored = { ids: ['t4', 't2'], focused: 't2', zoomed: null };
expect(T.tileGridOpenSet({ ...base, stored, split: ['t1', 't9'], activeId: 't1' })).toEqual({
source: 'stored',
ids: ['t4', 't2'],
focusedId: 't2',
});
expect(T.tileGridOpenSet({ ...base, stored: { ids: ['t4', 't2'], focused: 't4', zoomed: 't2' } })?.focusedId).toBe(
't2'
);
});
it('b: else an open split, Pane A focused', () => {
expect(T.tileGridOpenSet({ ...base, stored: { ids: [] }, split: ['t5', 't7'], activeId: 't5' })).toEqual({
source: 'split',
ids: ['t5', 't7'],
focusedId: 't5',
});
});
it('c: else the open sessions in tab order up to the limit, the active one focused', () => {
expect(T.tileGridOpenSet({ ...base, activeId: 't3' })).toEqual({
source: 'tabs',
ids: order.slice(0, 6),
focusedId: 't3',
});
});
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',
ids: ['t1', 't2', 't3', 't4', 't5', 't9'],
focusedId: 't9',
});
});
it('c: a window that takes fewer gets fewer; nothing ever passes the cap', () => {
expect(T.tileGridOpenSet({ ...base, limit: 4, activeId: 't1' })?.ids).toEqual(['t1', 't2', 't3', 't4']);
expect(T.tileGridOpenSet({ ...base, limit: 99, activeId: 't1' })?.ids).toHaveLength(T.TILE_GRID_MAX);
});
it('leaves out detached sessions and ones that no longer exist, as the picker does', () => {
const detachedIds = new Set(['t2']);
const out = T.tileGridOpenSet({ ...base, sessionOrder: ['gone', ...order], detachedIds, activeId: 't1' });
expect(out?.ids).toEqual(['t1', 't3', 't4', 't5', 't6', 't7']);
// A split whose Pane B was popped out: Pane A alone.
expect(T.tileGridOpenSet({ ...base, split: ['t1', 't2'], detachedIds, activeId: 't1' })?.ids).toEqual(['t1']);
});
it('is null when there is nothing to open', () => {
expect(T.tileGridOpenSet({ sessions: new Map(), sessionOrder: [], limit: 6, activeId: null })).toBeNull();
});
});
describe('the Tiles button and Ctrl+Shift+G', () => {
const button = () => {
const html = INDEX_HTML.match(/<button class="btn-icon-header btn-tile-grid[^>]*>/)?.[0] ?? '';
return html;
};
it('a click opens the grid at once (the toggle), a right-click opens the picker', () => {
expect(button()).toContain('onclick="app.toggleTileGrid()"');
expect(button()).toContain('oncontextmenu="app.openTilePicker(event)"');
expect(button()).toContain('right-click to choose which');
expect(button()).not.toContain('onclick="app.openTilePicker');
});
it('Ctrl+Shift+G runs the same toggle', () => {
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/tile-grid.js'), 'utf8');
expect(src).toContain("if (id === 'toggle-tile-grid') this.toggleTileGrid();");
});
});
describe('opening at once, in the app', () => {
const IDS = ['s-a', 's-b', 's-c'];
const picker = () => body.children.find((c) => c.id === 'tilePickerMenu') ?? null;
beforeEach(() => {
resetGridHarness();
const wrap = new FakeEl();
bySelector.set('.terminal-wrap', wrap);
});
it('a click with the grid closed shows the tiles, no picker', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-b';
app.toggleTileGrid();
expect(picker()).toBeNull();
expect(app._tilesOwnTerminal()).toBe(true);
expect(app._tileGrid.ids).toEqual(['s-other', ...IDS]);
expect(app.activeSessionId).toBe('s-b');
});
it('a click closes a picker that a right-click left open', () => {
const app = makeGridApp(IDS);
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(picker()).not.toBeNull();
app.toggleTileGrid();
expect(picker()).toBeNull();
expect(app._tilesOwnTerminal()).toBe(true);
});
it('a right-click opens the picker and keeps the browser menu away', () => {
const app = makeGridApp(IDS);
const ev = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
app.openTilePicker(ev);
expect(ev.preventDefault).toHaveBeenCalled();
expect(picker()).not.toBeNull();
expect(app._tilesOwnTerminal()).toBe(false);
});
it('a right-click with the grid open shows its tiles; Open replaces them', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b'], { focusedId: 's-b' });
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(app._tilesOwnTerminal()).toBe(true);
const menu = picker()!;
const boxes = menu.children[0].children.map((row) => row.children[0]);
expect(boxes.filter((b) => b.checked).map((b) => b.value)).toEqual(['s-a', 's-b']);
const boxA = boxes.find((b) => b.value === 's-a')!;
boxA.checked = false;
boxA.dispatch('change');
const boxC = boxes.find((b) => b.value === 's-c')!;
boxC.checked = true;
boxC.dispatch('change');
menu.children[1].children[1].dispatch('click');
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
});
});
+1 -1
View File
@@ -99,7 +99,7 @@ describe('the Tiles button', () => {
app.openTileGrid(IDS);
expect(btn.getAttribute('aria-pressed')).toBe('true');
expect(btn.classList.contains('tiles-open')).toBe(true);
app.openTilePicker({ stopPropagation: vi.fn() });
app.toggleTileGrid();
expect(app._tilesOwnTerminal()).toBe(false);
expect(btn.getAttribute('aria-pressed')).toBe('false');
expect(picker()).toBeNull();
+6 -4
View File
@@ -157,14 +157,15 @@ describe('the capture-phase handler', () => {
expect(app._tilesOwnTerminal()).toBe(false);
});
it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => {
it('Ctrl+Shift+G opens the grid on the open sessions (the active one focused), then closes it, overriding the browser', () => {
const app = withTilesSetting(makeGridApp(IDS));
app.selectSession = vi.fn();
const onKeydown = handlerFor(app);
const open = chord(TOGGLE);
onKeydown(open);
expect(open.preventDefault).toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(['s-a']);
expect(app._tileGrid.ids).toEqual(['s-other', ...IDS]);
expect(app.activeSessionId).toBe('s-a');
onKeydown(chord(TOGGLE));
expect(app._tilesOwnTerminal()).toBe(false);
@@ -215,7 +216,7 @@ describe('the actions', () => {
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
});
it('with every remembered session gone, it opens on the active session instead', () => {
it('with every remembered session gone, it opens on the open sessions in tab order instead', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-b', 's-c']);
app.closeTileGrid({ reselect: false });
@@ -223,7 +224,8 @@ describe('the actions', () => {
app.sessions.delete('s-c');
app.activeSessionId = 's-a';
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a']);
expect(app._tileGrid.ids).toEqual(['s-other', 's-a']);
expect(app.activeSessionId).toBe('s-a');
});
it('Remove Focused Tile removes it (the session keeps running) and a neighbour takes focus', () => {
+16
View File
@@ -65,6 +65,22 @@ describe('opening the grid over an open split', () => {
expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only
});
it('a remembered grid wins over an open split: exactly its tiles, the split closed and not merged', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-c']);
app.closeTileGrid({ reselect: false });
app.activeSessionId = 's-a';
const paneB = openSplit(app);
app.toggleTileGrid();
expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-c']);
expect(app.activeSessionId).toBe('s-c');
});
it('an explicit open over a split keeps both split sessions first', () => {
const app = makeGridApp(IDS);
openSplit(app);