mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user