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