// src/web/public/tile-grid.js /** * @fileoverview The tile grid: 1 to TILE_GRID_MAX (6) live sessions side by * side in one window, each in its own TerminalTile (terminal-tile.js), laid * out by count (window.CodemanTileGrid, constants.js). Desktop only; see * docs/tile-grid-plan.md. * * While the grid is open the main terminal (terminal-ui.js) is PARKED: hidden, * its socket closed, and every path that would write to it, fetch for it or * reconnect it stands aside (`_tilesOwnTerminal()`). `activeSessionId` always * names the FOCUSED tile's session, so everything keyed on it (files panel, * respawn and Ralph panels, subagent windows, voice, image paste, the tab * highlight) follows focus without knowing tiles exist. * * Every capture a tile fetches goes through the grid's one TileLoadQueue, * because each is a synchronous tmux call on the server. * * @dependency terminal-tile.js (window.TerminalTile, window.TileLoadQueue) * @dependency constants.js (window.CodemanTileGrid, SPLIT_PANE_MIN_WIDTH) * @loadorder 7.6 of 16, loaded after terminal-split.js and before respawn-ui.js */ // Per-device tile font size (a tile is a fraction of the screen). const TILE_GRID_FONT_KEY = 'codeman-tile-font-size'; // The grid this device last had, ids only (sanitizeTileGridState, constants.js): // `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, `ids` being the cells // in reading order with `null` for an empty one. `open: false` keeps it // remembered for one-click return; restored on reload inside handleInit. const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid'; // How many tiles a click on Tiles opens: the count last picked in its // right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so // the stored grid above stays ids only. const TILE_GRID_COUNT_KEY = 'codeman:tile-count'; // Trailing debounce for refitting tiles after the grid area changes size, so a // window drag sends each tile's PTY one resize, not one per frame. const TILE_GRID_REFIT_MS = 150; // Width of the draggable column and row dividers (their own grid tracks), and // the grid section's padding (styles.css .tile-grid), for the drag math. const TILE_DIVIDER_PX = 6; const TILE_GRID_PADDING_PX = 4; /** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */ function tileGridTracks(fr) { return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `); } // Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each // needs the grid open. The toggle applies wherever a grid could open. The arrow // chords (`direction`, `move`) leave a text field its keys (tileShortcutFor). const TILE_SHORTCUTS = { 'toggle-tile-grid': { needsOpen: false }, 'focus-tile-left': { needsOpen: true, direction: 'left' }, 'focus-tile-right': { needsOpen: true, direction: 'right' }, 'focus-tile-up': { needsOpen: true, direction: 'up' }, 'focus-tile-down': { needsOpen: true, direction: 'down' }, 'move-tile-left': { needsOpen: true, move: 'left' }, 'move-tile-right': { needsOpen: true, move: 'right' }, 'move-tile-up': { needsOpen: true, move: 'up' }, 'move-tile-down': { needsOpen: true, move: 'down' }, 'remove-tile': { needsOpen: true }, 'zoom-tile': { needsOpen: true }, }; /** * A text field other than a terminal's own input (xterm's helper textarea): * the rename input, the file editor, a settings field. Shifted arrows select * there (Ctrl+Shift by word; Option+Shift by word on macOS), so the arrow * chords, focus and move, leave it alone (owner: best practice). */ function isTextFieldTarget(target) { if (!target || target.classList?.contains?.('xterm-helper-textarea')) return false; const tag = String(target.tagName || '').toUpperCase(); return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true; } /** * The count menu's picture of `n` tiles: the grid's own layout for that count * (2x1, 2x2, 3x2) as small rounded cells. */ function tileCountGlyph(n) { const { cols, rows } = window.CodemanTileGrid.computeTileLayout({ count: n }); const NS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'tile-count-glyph'); svg.setAttribute('viewBox', '0 0 30 20'); svg.setAttribute('aria-hidden', 'true'); const gap = 3; const w = (30 - gap * (cols - 1)) / cols; const h = (20 - gap * (rows - 1)) / rows; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const rect = document.createElementNS(NS, 'rect'); rect.setAttribute('x', String(c * (w + gap))); rect.setAttribute('y', String(r * (h + gap))); rect.setAttribute('width', String(w)); rect.setAttribute('height', String(h)); rect.setAttribute('rx', '1.5'); svg.appendChild(rect); } } return svg; } /** The grid's state. `has(id)` answers only while it is open. */ class TileGridModel { constructor() { this.open = false; // The grid's cells in reading order (row-major), cols x rows of them: a // session id, or null for an empty cell. THE source of truth for where // each tile is (owner: an empty cell can be any cell); `ids` derives from it. this.cells = []; // id -> { tile: TerminalTile, el: HTMLElement } this.tiles = new Map(); this.focusedId = null; // The tile filling the grid (tmux zoom), or null. `autoZoom`: zoomed by the // grid itself because the window cannot fit the tiles; it follows focus and // lifts once the window fits again. this.zoomedId = null; this.autoZoom = false; // Track fractions (grid-template fr values) set by the dividers; equal // again whenever the column or row count changes. this.colFr = []; this.rowFr = []; // 'col-' / 'row-' -> the divider element between track i and i+1. this.dividers = new Map(); this.cols = 0; this.rows = 0; this.queue = null; this.resizeObserver = null; this.refitTimer = null; } has(id) { return this.open && this.tiles.has(id); } /** The tiled sessions in reading order, holes skipped (a fresh array: change `cells`, never this). */ get ids() { return this.cells.filter(Boolean); } } Object.assign(CodemanApp.prototype, { /** * True while the grid owns the terminal area and the main terminal is parked. * Every main-terminal path that would write, fetch, resize or reconnect checks * this and stands aside. */ _tilesOwnTerminal() { return !!this._tileGrid?.open; }, /** The open grid's TerminalTile for a session, or null. */ _tileFor(sessionId) { return this._tileGrid?.open ? (this._tileGrid.tiles.get(sessionId)?.tile ?? null) : null; }, /** Desktop only, never in a solo (popped-out) window; same gate as the split. */ canOpenTileGrid() { return !this.isSoloWindow && window.innerWidth >= SPLIT_PANE_MIN_WIDTH; }, _tileGridFontSize() { let saved = NaN; try { saved = parseInt(localStorage.getItem(TILE_GRID_FONT_KEY), 10); } catch { /* Storage unavailable: the default below. */ } return saved >= 10 && saved <= 24 ? saved : window.CodemanTileGrid.TILE_FONT_SIZE_DEFAULT; }, /** Ctrl +/- while the grid is open: every tile, then each tile's PTY (a font change is a size change, #464). */ setTileFontSize(size) { try { localStorage.setItem(TILE_GRID_FONT_KEY, String(size)); } catch { /* Per-device convenience only. */ } for (const { tile } of this._tileGrid?.tiles.values() || []) { tile.fontSize = size; if (!tile.terminal) continue; tile.terminal.options.fontSize = size; tile.fit(); } }, _tileLoadQueue() { const grid = this._tileGrid; if (!grid.queue) { grid.queue = new window.TileLoadQueue({ // The focused tile first, then reading order. rank: (tile) => (tile.sessionId === grid.focusedId ? -1 : grid.ids.indexOf(tile.sessionId)), // A quiet "loading" state on a tile until its capture has landed. onChange: (tile, state) => { const entry = grid.tiles.get(tile.sessionId); if (entry?.tile === tile) entry.el.classList.toggle('tile--loading', state !== 'idle'); }, }); } return grid.queue; }, _tileGridSection() { let section = document.getElementById('tileGrid'); if (!section) { section = document.createElement('section'); section.id = 'tileGrid'; section.className = 'tile-grid'; section.setAttribute('aria-label', 'Tiled sessions'); const wrap = document.querySelector('.terminal-wrap'); wrap?.parentElement?.insertBefore(section, wrap.nextSibling); } return section; }, /** * 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). 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, mergeSplit = true } = {}) { if (!this.canOpenTileGrid()) return false; const grid = (this._tileGrid ||= new TileGridModel()); const max = window.CodemanTileGrid.TILE_GRID_MAX; // The grid and the split are never open together. An open split becomes the // grid's first two tiles (Pane A focused, Pane B beside it), so "split, then // want more" is one step. No closing resize for Pane A: it is about to park. let requested = ids || []; if (this._splitPane) { const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean); this.closeSplitPane({ skipPrimaryResize: true }); if (mergeSplit) { requested = [...seed, ...requested]; if (!requested.includes(focusedId)) focusedId = seed[0] ?? null; } } const wanted = []; for (const id of requested) { if (typeof id !== 'string' || wanted.includes(id)) continue; if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue; wanted.push(id); if (wanted.length === max) break; } if (wanted.length === 0) return grid.open; const focus = wanted.includes(focusedId) ? focusedId : wanted[0]; if (grid.open) { for (const id of wanted) this.addTile(id); if (grid.has(focus)) this._selectTiledSession(focus, { auto }); return true; } // No snapshot of a session that becomes a tile: closing the grid drops the // main terminal's snapshot of every tiled id (stale by then), so taking one // here (a 1000-line serialize and up to 256 KB of localStorage for a // non-shell session) only ever produced a copy that was thrown away. this._cleanupPreviousSession(focus, { skipSnapshot: wanted.includes(this.activeSessionId) }); grid.open = true; grid.cells = []; grid.cols = 0; grid.rows = 0; grid.focusedId = focus; document.querySelector('.main')?.classList.add('tiles-active'); const section = this._tileGridSection(); this.hideWelcome(); // Mount every tile and lay the grid out BEFORE any tile connects, so each // first fit measures its real cell; the focused tile connects first, so // its capture is the one the queue starts with. for (const id of wanted) this._mountTile(id); // Packed from the first cell (_applyTileLayout pads the shape with empty cells). grid.cells = wanted.filter((id) => grid.tiles.has(id)); this._applyTileLayout(); for (const id of [focus, ...wanted.filter((id) => id !== focus)]) this._connectTile(id); if (!grid.resizeObserver && typeof ResizeObserver !== 'undefined') { // The main terminal's observer watches a node that is now hidden; this one // catches window resizes, sidebar toggles and rail drags for the grid. grid.resizeObserver = new ResizeObserver(() => this._scheduleTileGridRefit()); grid.resizeObserver.observe(section); } this._installTileGridWidthGate(); this._selectTiledSession(focus, { auto }); this._updateConnectionIndicator?.(); this._updateSplitButtonForTiles(); this._updateTileGridButtonState(); return true; }, /** * Leaves the grid: every tile destroyed (sockets closed, xterms disposed, * queued loads dropped), the main terminal unparked. * * `keepStored` remembers the grid for one-click return (toggleTileGrid). * `reselect` shows the focused session in the single view through a forced * reload; pass false when the caller selects something itself. * * The main terminal's cached content for EVERY tiled id is invalidated: it was * written before the grid opened, possibly hours ago, and selectSession paints * a snapshot as its first frame. */ closeTileGrid({ keepStored = true, reselect = true } = {}) { const grid = this._tileGrid; if (!grid?.open) return; const focusedId = grid.focusedId; const ids = grid.ids.slice(); // Remembered (closed) for one-click return, or forgotten. if (keepStored) this._persistTileGrid({ open: false }); else this._forgetStoredTileGrid(); // A divider drag or a header drag in progress ends with the grid. this._tileDividerDragTeardown?.(); this._endTileMoveDrag(); // Closed BEFORE the tiles go: each destroy() updates the header's connection // state, which must read the main terminal again, not half-destroyed tiles. grid.open = false; clearTimeout(grid.refitTimer); grid.refitTimer = null; grid.resizeObserver?.disconnect(); grid.resizeObserver = null; for (const { tile, el } of grid.tiles.values()) { this._destroyTerminalTile(tile); el.remove(); } grid.tiles.clear(); for (const el of grid.dividers.values()) el.remove(); grid.dividers.clear(); for (const slot of grid.slots || []) slot.remove(); grid.slots = []; grid.colFr = []; grid.rowFr = []; grid.cells = []; grid.cols = 0; grid.rows = 0; grid.focusedId = null; grid.zoomedId = null; grid.autoZoom = false; document.querySelector('.main')?.classList.remove('tiles-active'); const section = document.getElementById('tileGrid'); if (section) { section.style.gridTemplateColumns = ''; section.style.gridTemplateRows = ''; section.classList.remove('tile-grid--zoomed'); } // As _redock does: the tiles sized these PTYs, so the main terminal's // record of the last size it sent no longer describes them. this._lastResizeDims = null; for (const id of ids) { this._xtermSnapshots?.delete(id); try { localStorage.removeItem(`codeman-xs-${id}`); } catch { /* Nothing stored. */ } this.terminalBufferCache?.delete(id); } this._updateConnectionIndicator?.(); this._updateSplitButtonForTiles(); this._updateTileGridButtonState(); // 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 (right-click for how many tiles)' : 'Tiles: show several sessions side by side (right-click for how many)'; btn.title = title; btn.setAttribute('aria-label', title); }, /** * How many tiles the grid takes here and now: what the terminal area fits * (the grid section, or the single view it would replace), at most the cap * (TILE_GRID_MAX), never less than one. `full` says which of the two binds, * so a large monitor never reads "this window fits 6". */ _tileGridLimit() { const T = window.CodemanTileGrid; const max = T.TILE_GRID_MAX; const el = this._tilesOwnTerminal() ? this._tileGridSection() : document.querySelector('.terminal-wrap'); const rect = el?.getBoundingClientRect?.() || { width: 0, height: 0 }; const fits = T.tileGridCapacity({ width: rect.width || window.innerWidth, height: rect.height || window.innerHeight, }); const capacity = Math.max(1, Math.min(fits, max)); const byCap = fits >= max; return { capacity, full: byCap ? `The grid holds at most ${max} tiles` : `The grid already holds what this window fits (${capacity})`, }; }, /** The tile count a click on Tiles opens (the menu's last pick, per device), default 6. */ _tileGridCount() { let raw = null; try { raw = localStorage.getItem(TILE_GRID_COUNT_KEY); } catch { /* Storage unavailable: the default. */ } return window.CodemanTileGrid.sanitizeTileCount(raw); }, _rememberTileGridCount(count) { try { localStorage.setItem(TILE_GRID_COUNT_KEY, String(window.CodemanTileGrid.sanitizeTileCount(count))); } catch { /* Per-device convenience only. */ } }, /** * Right-click on the Tiles button (or Shift+F10 / the Menu key on it, which * fire the same contextmenu event; its click opens the grid at once, see * toggleTileGrid): how many tiles, 2, 4 or 6 (owner decision 10), each with * its shape, the remembered count checked. A count the window cannot fit is * greyed out, and the menu says why; the keyboard then starts on the largest * that fits (the count a click opens here). Picking one opens the grid with * that many tiles, or re-forms the open grid to it, and remembers it for the * click. Arrows move, Enter or Space picks, Escape closes and gives the * keyboard back to the Tiles button, Tab closes. */ openTileCountMenu(event) { // The right-click: the browser's own context menu stays away. event?.preventDefault?.(); if (this._tileCountMenu) { this.closeTileCountMenu(); return; } if (!this.canOpenTileGrid()) return; const T = window.CodemanTileGrid; const capacity = this._tileGridLimit().capacity; const fitsText = `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`; const current = this._tileGridCount(); const menu = document.createElement('div'); menu.id = 'tileCountMenu'; menu.className = 'tile-count-menu'; menu.setAttribute('role', 'menu'); menu.setAttribute('aria-label', 'How many tiles'); const heading = document.createElement('div'); heading.className = 'tile-count-heading'; heading.setAttribute('aria-hidden', 'true'); heading.textContent = 'Tiles'; menu.appendChild(heading); const items = []; for (const n of T.TILE_GRID_COUNTS) { const item = document.createElement('button'); item.type = 'button'; item.className = 'tile-count-item'; item.setAttribute('role', 'menuitemradio'); item.setAttribute('aria-checked', n === current ? 'true' : 'false'); item.tabIndex = -1; item.dataset.count = String(n); if (n > capacity) { item.disabled = true; item.setAttribute('aria-disabled', 'true'); item.title = fitsText; } const label = document.createElement('span'); label.className = 'tile-count-label'; label.textContent = `${n} tiles`; const check = document.createElement('span'); check.className = 'tile-count-check'; check.setAttribute('aria-hidden', 'true'); check.textContent = n === current ? '\u2713' : ''; item.append(tileCountGlyph(n), label, check); item.addEventListener('click', (e) => { e.stopPropagation?.(); if (!item.disabled) this._pickTileCount(n); }); item.addEventListener('pointerenter', () => { if (!item.disabled) item.focus?.(); }); menu.appendChild(item); items.push(item); } if (capacity < T.TILE_GRID_MAX) { const hint = document.createElement('div'); hint.className = 'tile-count-hint'; hint.textContent = fitsText; menu.appendChild(hint); } document.body.appendChild(menu); const btn = document.querySelector('.btn-tile-grid'); if (btn?.getBoundingClientRect) { // Under the Tiles button (.tile-count-menu is position: fixed). const rect = btn.getBoundingClientRect(); menu.style.top = `${rect.bottom + 6}px`; menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`; } const enabled = () => items.filter((i) => !i.disabled); const move = (delta) => { const list = enabled(); if (list.length === 0) return; const at = list.indexOf(document.activeElement); const next = at === -1 ? (delta > 0 ? 0 : list.length - 1) : (at + delta + list.length) % list.length; list[next].focus?.(); }; // A click elsewhere closes it. A click on the Tiles button needs no // exception: its own handler (toggleTileGrid) closes the menu first. const onOutside = (e) => { if (menu.contains?.(e.target)) return; this.closeTileCountMenu(); }; // The menu's keys, in the capture phase while it is open. Escape normally // reaches the app's global handler first, which closes the menu alone and // gives the keyboard back (app.js); this one covers the rest. const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault?.(); this.closeTileCountMenu({ refocus: true }); } else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault?.(); move(1); } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault?.(); move(-1); } else if (e.key === 'Home' || e.key === 'End') { e.preventDefault?.(); const list = enabled(); list[e.key === 'Home' ? 0 : list.length - 1]?.focus?.(); } else if (e.key === 'Enter' || e.key === ' ') { const item = items.find((i) => i === document.activeElement); if (!item) return; // Not the button's own activation too. e.preventDefault?.(); if (!item.disabled) this._pickTileCount(Number(item.dataset.count)); } else if (e.key === 'Tab') { this.closeTileCountMenu(); } }; this._tileCountMenu = { menu, onOutside, onKey }; document.addEventListener('click', onOutside); document.addEventListener('keydown', onKey, true); // The checked count, or when the window cannot fit it, the largest that fits. const start = items.find((i) => !i.disabled && i.getAttribute('aria-checked') === 'true') || enabled().at(-1); (start || menu).focus?.(); }, /** * Idempotent: the global Escape handler may call it whether or not the menu * is open. The keyboard goes back to the Tiles button when it was in the * menu (`refocus` unset), or when asked (Escape); a click elsewhere has * already moved it, and a pick passes `refocus: false`. */ closeTileCountMenu({ refocus } = {}) { const menu = this._tileCountMenu; if (!menu) return; if (refocus === undefined) refocus = !!menu.menu.contains?.(document.activeElement); this._tileCountMenu = null; document.removeEventListener('click', menu.onOutside); document.removeEventListener('keydown', menu.onKey, true); menu.menu.remove(); if (refocus) document.querySelector('.btn-tile-grid')?.focus?.(); }, /** * A count picked in the menu: remembered for the click, then the grid opens * with that many tiles (what the click opens), or the open grid is re-formed * to it (_reformTileGrid). Never more than the window fits; fewer open * sessions than the count give fewer tiles. */ _pickTileCount(count) { this.closeTileCountMenu({ refocus: false }); this._rememberTileGridCount(count); const grid = this._tileGrid; if (!grid?.open) { this.toggleTileGrid(); return; } const T = window.CodemanTileGrid; const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity); const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id); this._reformTileGrid(T.tileGridSetForCount(grid.ids, all, n, grid.focusedId)); }, /** * The open grid re-formed to the tiles `target` (a count picked in the * menu): a count change is a shape change, so the cell model's rule applies * (reformTileCells: the tiles that stay keep their row and column when all * fit, else they pack in reading order), and the tiles that join fill the * empty cells in reading order, holes first. The focused tile is always * among those that stay (tileGridSetForCount). Every tile that joins is * mounted and the grid laid out ONCE before any of them connects (as in * openTileGrid): a tile connected into an intermediate layout would fit that * cell first and resize its PTY twice. A zoom the user chose ends: the grid * they asked for is meant to be seen. */ _reformTileGrid(target) { const grid = this._tileGrid; if (!grid?.open) return; const T = window.CodemanTileGrid; const leaving = grid.ids.filter((id) => !target.includes(id)); const joining = target.filter((id) => !grid.tiles.has(id)); if (leaving.length === 0 && joining.length === 0) return; // A divider or header drag in progress measured the old grid. this._tileDividerDragTeardown?.(); this._endTileMoveDrag(); for (const id of leaving) { const entry = grid.tiles.get(id); this._destroyTerminalTile(entry.tile); entry.el.remove(); grid.tiles.delete(id); } if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; const staying = grid.ids.filter((id) => grid.tiles.has(id)); const mounted = joining.filter((id) => this._mountTile(id)); const { cols, rows } = this._tileShapeFor(grid.tiles.size); grid.cells = T.reformTileCells(grid.cells, grid.cols, staying, mounted, cols, rows); grid.cols = cols; grid.rows = rows; this._applyTileLayout(); for (const id of mounted) this._connectTile(id); this._scheduleTileGridRefit(); this.renderSessionTabs?.(); }, /** * A session THIS tab's Run just created (session-ui.js * _ensureCreatedSessionVisible, reached only from the Run paths): with the * grid open it joins the next free slot, and Run's own selectSession then * focuses it through the tile branch. Sessions created elsewhere (agents, * other devices, cron) arrive only by session:created and never join. A grid * already holding what the window fits does not take it; Run's selection * then shows it in the single view (decision 1), and a hint says why. */ _joinTileGridFromRun(sessionId) { const grid = this._tileGrid; if (!grid?.open || grid.tiles.has(sessionId) || !this.sessions.has(sessionId)) return false; const limit = this._tileGridLimit(); if (grid.ids.length >= limit.capacity) { this.showToast?.(`${limit.full}: the new session opens on its own`, 'info'); return false; } // Run starts the session right after creating it: no Attach overlay // meanwhile for a pane that is about to exist. (this._tileAttachPending ||= new Map()).set(sessionId, Date.now()); return this.addTile(sessionId); }, // 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. _updateSplitButtonForTiles() { const btn = document.querySelector('.btn-split'); if (!btn) return; const blocked = this._tilesOwnTerminal(); btn.classList.toggle('btn-split--blocked', blocked); btn.setAttribute('aria-disabled', blocked ? 'true' : 'false'); if (blocked) { btn.title = 'Split: unavailable while tiles are open'; btn.setAttribute('aria-label', btn.title); } else { this._updateSplitButtonState?.(!!this._splitPane); } }, /** * The tile chord `e` asks for, if it applies right now, else null. The * toggle applies while the grid is open, or where one could open AND the * per-device `showTileGridButton` setting is on: with it off (the default) * the chord is inert and reaches the terminal like any unbound key (owner * decision 6 in docs/tile-grid-plan.md). The focus, move, * zoom and remove chords apply only while the grid is open, however it was * opened (a move chord also while a tile is zoomed, as a no-op, so its keys * never reach the CLI). The arrow chords never apply in a text field, whose * keys they are. * Registry-aware (rebinds and disables in App Settings, Shortcuts). * The capture handler (app.js) dispatches it; every xterm key handler returns * false for it, so a chord that applies never reaches a PTY. * * @returns {string|null} the registry id */ tileShortcutFor(e) { if (!e || (!e.ctrlKey && !e.metaKey && !e.altKey)) return null; if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return null; const open = this._tilesOwnTerminal(); for (const shortcut of this.getShortcutRegistry()) { const spec = TILE_SHORTCUTS[shortcut.id]; if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue; if ((spec.direction || spec.move) && isTextFieldTarget(e.target)) continue; if (spec.needsOpen) return open ? shortcut.id : null; const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true; return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null; } return null; }, /** Runs a chord tileShortcutFor() matched. */ runTileShortcut(id) { const spec = TILE_SHORTCUTS[id]; if (!spec) return; if (id === 'toggle-tile-grid') this.toggleTileGrid(); else if (id === 'remove-tile') this.removeFocusedTile(); else if (id === 'zoom-tile') this.zoomTile(this._tileGrid?.focusedId); else if (spec.direction) this.focusTileInDirection(spec.direction); else if (spec.move) this.moveTileInDirection(spec.move); }, /** * 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 menu in the way, or * closes it to the single view of the focused session. What opens is the * remembered count of tiles (the right-click menu's last pick, default 6, * at most what the window fits; owner decision 10), chosen by * `tileGridOpenSet` (constants.js): the grid this tab last had, else an open * split's two sessions, else the open sessions in tab order, the active one * focused; then trimmed or filled to the count (the focused one kept). A * remembered grid comes back with its tiles in their cells, the ones the * count adds filling its empty cells first (_openStoredTileGrid). */ toggleTileGrid() { this.closeTileCountMenu(); if (this._tilesOwnTerminal()) { this.closeTileGrid({ keepStored: true, reselect: true }); return; } if (!this.canOpenTileGrid()) return; const stored = this._readStoredTileGrid(); const set = this._tileGridOpenSet(stored); if (!set) { this.showToast?.('No sessions to show as tiles', 'info'); return; } if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids); else this.openTileGrid(set.ids, { focusedId: set.focusedId }); }, /** * What the toggle would open now (see toggleTileGrid): `count` tiles (the * remembered count), or as many as the window fits; `stored` saves a * second read. */ _tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) { const T = window.CodemanTileGrid; const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity)); const set = T.tileGridOpenSet({ stored, split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null, sessions: this.sessions, sessionOrder: this.sessionOrder, detachedIds: this.detachedSessions, activeId: this.activeSessionId, limit: n, }); if (!set) return null; const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id); return { ...set, ids: T.tileGridSetForCount(set.ids, all, n, set.focusedId) }; }, /** Alt+Shift+Arrows: a human selection of the tile in that direction. */ focusTileInDirection(direction) { const grid = this._tileGrid; if (!grid?.open) return; // Over the cells: focus skips an empty one, never lands on it. const id = window.CodemanTileGrid.tileInDirection(grid.cells, grid.focusedId, direction, grid.cols); if (id) this.selectSession(id); }, /** * Ctrl+Tab / Alt+] (delta 1) and Alt+[ (delta -1) while the grid is open: a * human selection of the next tile in reading order, wrapping. Returns * whether the grid took the chord (it is open), so the tab walk is skipped. */ _cycleTileFocus(delta) { const grid = this._tileGrid; if (!grid?.open) return false; const id = window.CodemanTileGrid.cycleTile(grid.ids, this.activeSessionId, delta); if (id) this.selectSession(id); return true; }, /** Removes the focused tile (the session keeps running); a neighbour takes focus. */ removeFocusedTile() { const grid = this._tileGrid; if (!grid?.open || !grid.focusedId) return; this.removeTile(grid.focusedId); }, // The single view after the grid closes: the session the grid was focused on, // replayed fresh (forceReload drops the stale snapshot and nulls // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), // or, if that session is gone, the same fallback as closing the active tab. _selectAfterTileGrid(sessionId) { if (sessionId && this.sessions.has(sessionId)) { this.selectSession(sessionId, { forceReload: true, auto: true }); return; } this.activeSessionId = null; try { localStorage.removeItem('codeman-active-session'); } catch { /* Nothing stored. */ } const next = this.sessionOrder.find((id) => this.sessions.has(id)); if (next) { this.selectSession(next, { auto: true }); } else { this.terminal?.clear(); this.showWelcome(); } }, /** * Adds one session as a tile (open grid only), into `cell` when that cell is * empty (a tab dropped on it), else the first empty cell in reading order * (_placeTile). Returns whether it was added. */ addTile(sessionId, { cell = -1 } = {}) { const grid = this._tileGrid; if (!grid?.open || grid.tiles.has(sessionId)) return false; if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false; if (!this._mountTile(sessionId)) return false; this._placeTile(sessionId, cell); // A tile added while one is zoomed by hand is meant to be seen. if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; this._applyTileLayout(); this._connectTile(sessionId); this._scheduleTileGridRefit(); this.renderSessionTabs?.(); return true; }, /** * Puts a tile just mounted into a cell. The shape for one more tile comes * first (fitTileCells: the same shape keeps every cell; 2x2 growing to 3x2 * keeps each tile where it is), then `cell` if it is empty there, else the * first empty cell in reading order. */ _placeTile(sessionId, cell = -1) { const grid = this._tileGrid; const { cols, rows } = this._tileShapeFor(grid.ids.length + 1); grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows); grid.cols = cols; grid.rows = rows; const k = Number.isInteger(cell) && grid.cells[cell] === null ? cell : grid.cells.indexOf(null); // (No empty cell cannot happen: the shape for n tiles has at least n cells. // Appended, the next layout packs it in.) if (k === -1) grid.cells.push(sessionId); else grid.cells[k] = sessionId; }, /** Columns x rows (and whether they fit) for `count` tiles in the grid area as it is now. */ _tileShapeFor(count) { const rect = this._tileGridSection().getBoundingClientRect?.() || { width: 0, height: 0 }; return window.CodemanTileGrid.computeTileLayout({ count, width: rect.width || window.innerWidth, height: rect.height || window.innerHeight, }); }, /** * Removes one tile; the session keeps running. Its cell becomes empty where * it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus` * moves focus to the neighbouring tile (next in grid order, else previous), * as the app's choice (`auto`: no idle alert is spent). The last tile * leaving closes the grid: with `refocus` the single view then shows that * session, without it the caller decides what comes next. */ removeTile(sessionId, { refocus = true } = {}) { const grid = this._tileGrid; const entry = grid?.open ? grid.tiles.get(sessionId) : null; if (!entry) return false; if (grid.ids.length === 1) { this.closeTileGrid({ keepStored: false, reselect: refocus }); return true; } const wasFocused = grid.focusedId === sessionId; const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId); // A divider drag in progress was measured against this tile; a header // drag of this tile has nothing left to drop. this._tileDividerDragTeardown?.(); if (this._draggedTileId === sessionId) this._endTileMoveDrag(); // The zoomed tile leaving restores the grid (an automatic zoom moves to // the neighbour with focus, below). if (grid.zoomedId === sessionId) grid.zoomedId = grid.autoZoom && refocus ? neighbor : null; this._destroyTerminalTile(entry.tile); entry.el.remove(); grid.tiles.delete(sessionId); grid.cells[grid.cells.indexOf(sessionId)] = null; // Before the layout, which may zoom the focused tile on a small window. if (wasFocused) grid.focusedId = null; this._applyTileLayout(); this._scheduleTileGridRefit(); this.renderSessionTabs?.(); if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true }); return true; }, _mountTile(sessionId) { const grid = this._tileGrid; const session = this.sessions.get(sessionId); if (!session || this.detachedSessions?.has(sessionId)) return false; const el = document.createElement('div'); el.className = 'tile'; el.dataset.sessionId = sessionId; // Header and body are siblings: the chrome is refreshed in place // (_renderTileHeader), never by rewriting the tile, which would take the // xterm in the body with it. const header = this._buildTileHeader(sessionId); const body = document.createElement('div'); body.className = 'tile-body'; el.append(header.el, body); // Pressing a tile is a human selection: it focuses the tile and // acknowledges its idle alert (the already-focused tile hits // selectSession's early return, which acknowledges too). In the body on // pointerdown, not click, so focus moves before the press reaches xterm, // and never preventDefault: xterm's own mousedown focuses its textarea and // starts selections. In the header on click instead (below): a press there // may become a drag, and a drag that is cancelled changes nothing, focus // included (owner: best practice). A drag never ends in a click. el.addEventListener('pointerdown', (e) => { if (header.el.contains?.(e?.target)) return; if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); }); header.el.addEventListener('click', () => { if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); }); // A tile takes any session but its own (_acceptTabDrops). this._acceptTabDrops(el, (draggedId) => this.dropSessionOnTile(draggedId, sessionId), { accepts: (id) => id !== sessionId, }); this._tileGridSection().appendChild(el); const tile = this._newTerminalTile(sessionId, body); grid.tiles.set(sessionId, { tile, el, body, overlay: null, header: header.el, dot: header.dot, harness: header.harness, name: header.name, model: header.model, modelName: header.modelName, zoomBtn: header.zoomBtn, renaming: false, // The pid this tile last saw, so a pane that starts later is noticed. pid: this.sessions.get(sessionId)?.pid ?? null, }); // Where it goes is the caller's (grid.cells). this._renderTileHeader(sessionId); return true; }, /** * Makes `el` a drop target for a session tab dragged from the strip (the * strip's own drag sets `draggedTabId`) and for a tile dragged by its header * (`_draggedTileId`, _installTileMoveDrag). Capture phase, with the event * stopped: a tab drag carries the session id as text, and xterm's helper * textarea would otherwise accept that drop and type the id into a PTY. * `accepts(id)` is the target's own rule (a tile takes any session but its * own; an empty cell takes any). A session it does not accept * is held there too, but refused (`dropEffect: 'none'`, no highlight, so no * drop follows). Any other drag (a file) is left alone. */ _acceptTabDrops(el, onDrop, { accepts = () => true } = {}) { const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null); el.addEventListener( 'dragover', (e) => { const id = dragged(); if (!id) return; e.preventDefault?.(); e.stopPropagation?.(); const ok = accepts(id); if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none'; if (ok) el.classList.add('tile--drop-target'); }, true ); el.addEventListener('dragleave', (e) => { if (!el.contains?.(e.relatedTarget)) el.classList.remove('tile--drop-target'); }); el.addEventListener( 'drop', (e) => { el.classList.remove('tile--drop-target'); const id = dragged(); if (!id) return; e.preventDefault?.(); e.stopPropagation?.(); if (accepts(id)) onDrop(id); }, true ); }, /** Whether tiles can be moved now: more than one, and none zoomed (moving is off while one is). */ _tilesMovable() { const grid = this._tileGrid; return !!grid?.open && !grid.zoomedId && grid.ids.length > 1; }, /** * Puts the tiles in new cells (the same sessions, the same shape): the one * path every move takes, a header drag, the tab of a tiled session dropped * on a tile or an empty cell, and the Move Tile chords. Nothing is * remounted, reconnected or reloaded, and no session joins or leaves. * Divider sizes belong to the cells, so a moved tile takes its new cell's * size: each tile whose cell size changed fits once (its xterm and one PTY * resize together, #464), every other tile is left alone. Refused while a * tile is zoomed (moving is off then). * * @param {(string|null)[]} cells - the new cells * @returns {boolean} false when refused, true otherwise (also when nothing moved) */ _reorderTiles(cells) { const grid = this._tileGrid; if (!grid?.open || grid.zoomedId || cells.length !== grid.cells.length) return false; const tiles = cells.filter(Boolean); if (tiles.length !== grid.tiles.size || new Set(tiles).size !== tiles.length) return false; if (tiles.some((id) => !grid.tiles.has(id))) return false; if (cells.every((id, k) => (id || null) === grid.cells[k])) return true; // A divider drag in progress measured the tiles at their old places. this._tileDividerDragTeardown?.(); const cell = (k) => `${grid.colFr[k % grid.cols]}x${grid.rowFr[Math.floor(k / grid.cols)]}`; const before = new Map(); grid.cells.forEach((id, k) => id && before.set(id, cell(k))); grid.cells = cells.map((id) => id || null); this._applyTileLayout(); // Synchronous: the fit's measurement forces the new placement's layout. grid.cells.forEach((id, k) => { if (id && before.get(id) !== cell(k)) grid.tiles.get(id).tile.fit(); }); return true; }, /** Two tiles trade cells (_reorderTiles). */ _swapTiles(a, b) { const cells = this._tileGrid.cells.slice(); const i = cells.indexOf(a); const j = cells.indexOf(b); if (i === -1 || j === -1) return false; cells[i] = b; cells[j] = a; return this._reorderTiles(cells); }, /** * A tile moves into empty cell `k`, leaving its own cell empty: nothing else * moves (_reorderTiles). False when `k` is not an empty cell, or moving is off. */ _moveTileToCell(sessionId, k) { const cells = this._tileGrid.cells.slice(); const from = cells.indexOf(sessionId); if (from === -1 || !Number.isInteger(k) || cells[k] !== null) return false; cells[from] = null; cells[k] = sessionId; return this._reorderTiles(cells); }, /** * Move Tile Left/Right/Up/Down: the focused tile goes to the cell next to it * in that direction (tileCellInDirection, never jumping a cell): into it * when it is empty, trading places when a tile is there. Focus stays on the * moved tile. Nothing while a tile is zoomed, or at an edge. */ moveTileInDirection(direction) { const grid = this._tileGrid; if (!grid?.open || grid.zoomedId || !grid.focusedId) return; const from = grid.cells.indexOf(grid.focusedId); const to = window.CodemanTileGrid.tileCellInDirection(from, direction, grid.cols, grid.cells.length); if (to === -1) return; if (grid.cells[to]) this._swapTiles(grid.focusedId, grid.cells[to]); else this._moveTileToCell(grid.focusedId, to); }, /** * A tab dropped on a tile, or a tile dragged there by its header: a session * not yet tiled REPLACES that tile (same place; the replaced session keeps * running); one already tiled swaps places with it (_swapTiles, refused while * a tile is zoomed). Either way the dropped session takes focus (a human * selection). */ dropSessionOnTile(draggedId, targetId) { const grid = this._tileGrid; if (!grid?.open || draggedId === targetId || !grid.tiles.has(targetId)) return; if (!this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; if (grid.tiles.has(draggedId)) { if (!this._swapTiles(draggedId, targetId)) return; } else { this._tileDividerDragTeardown?.(); if (!this._mountTile(draggedId)) return; // It takes the replaced tile's cell. grid.cells[grid.cells.indexOf(targetId)] = draggedId; const old = grid.tiles.get(targetId); this._destroyTerminalTile(old.tile); old.el.remove(); grid.tiles.delete(targetId); if (grid.zoomedId === targetId) grid.zoomedId = grid.autoZoom ? draggedId : null; if (grid.focusedId === targetId) grid.focusedId = null; this._applyTileLayout(); this._connectTile(draggedId); this._scheduleTileGridRefit(); this.renderSessionTabs?.(); } this.selectSession(draggedId); }, /** * Something dropped on empty cell `cell`: a tab of a session not tiled yet * joins the grid in that cell; a tiled session (its tab, or the tile dragged * by its header) moves into it, leaving its own cell empty * (_moveTileToCell, refused while a tile is zoomed). Either way the dropped * session takes focus (a human selection). */ dropSessionOnSlot(draggedId, cell) { const grid = this._tileGrid; if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; if (grid.tiles.has(draggedId)) { if (!this._moveTileToCell(draggedId, cell)) return; } else if (!this.addTile(draggedId, { cell })) { return; } this.selectSession(draggedId); }, /** * Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a * human selection: the user clicked its tab). With the grid closed it opens * what the Tiles toggle would, with this session among the tiles and * focused: the remembered count in total (owner answer to decision 10's * questions), never one more. Returns false * when the grid cannot open here (narrow or solo window), so the click is an * ordinary one. */ addSessionToTiles(sessionId) { if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) { return false; } const limit = this._tileGridLimit(); const capacity = limit.capacity; const grid = this._tileGrid; if (grid?.open) { if (!grid.tiles.has(sessionId)) { if (grid.ids.length >= capacity) { this.showToast?.(limit.full, 'info'); return true; } this.addTile(sessionId); } this.selectSession(sessionId); return true; } const n = Math.max(1, Math.min(this._tileGridCount(), capacity)); const base = this._tileGridOpenSet()?.ids || []; const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId]; this.openTileGrid(ids, { focusedId: sessionId, auto: false }); return true; }, /** * "Open group as tiles" (the tab-group menu of the grouped rail): the * group's live sessions, as many as the window fits, become the grid, * replacing whatever it showed. Opening the grid is the app's choice of * focus, so no idle alert is spent. */ openGroupAsTiles(groupId) { const group = (this.tabLayout?.groups || []).find((g) => g.id === groupId); if (!group || !this.canOpenTileGrid()) return false; const capacity = this._tileGridLimit().capacity; const ids = (group.refs || []) .filter((ref) => ref.kind === 'session') .map((ref) => ref.id) .filter((id) => this.sessions.has(id) && !this.detachedSessions?.has(id)) .slice(0, capacity); if (ids.length === 0) { this.showToast?.('This group has no session to show as tiles', 'info'); return false; } return this._replaceTileGrid(ids); }, /** * Opens the grid on `ids` in place of whatever it shows ("Open group as * tiles"): the session in focus keeps focus when `ids` holds * it, otherwise the first one takes it. Chosen BEFORE an open grid closes, * because the close drops activeSessionId: as in selectSession's tile * branch, the parked terminal still holds what it showed before the grid, * and re-parking must not snapshot it. * * @returns {boolean} whether the grid is open afterwards */ _replaceTileGrid(ids) { const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0]; if (this._tilesOwnTerminal()) { this.closeTileGrid({ keepStored: false, reselect: false }); this.activeSessionId = null; } return this.openTileGrid(ids, { focusedId: focus }); }, /** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */ _newTerminalTile(sessionId, body) { const session = this.sessions.get(sessionId); const tile = new window.TerminalTile(sessionId, body, { mode: session?.mode, fontSettings: this.loadAppSettingsFromStorage?.() || {}, detachedSessions: this.detachedSessions, scheduleLoad: (t, kind, run) => this._tileLoadQueue().schedule(t, kind, run), scrollback: window.CodemanTileGrid.TILE_SCROLLBACK, fontSize: this._tileGridFontSize(), boundedLoad: true, onExit: (code) => this._onTileExit(sessionId, tile, code), }); return tile; }, /** * A grid tile's TerminalTile goes for good: its loads still waiting in the * grid's queue are dropped first (resolved, never run, its loading state * cleared), then the tile itself (socket, xterm, listeners). Its element is * the caller's. */ _destroyTerminalTile(tile) { this._tileGrid.queue?.drop(tile); tile.destroy(); }, /** * Replaces a tile's TerminalTile with a fresh one in the same place (after * Attach: a tile whose socket stopped for good cannot reconnect, and a fresh * one loads the new pane from scratch). Keeps the keyboard if it had it. */ _remountTile(sessionId) { const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null; if (!entry) return; const hadKeyboard = this._focusedTile === entry.tile; this._destroyTerminalTile(entry.tile); entry.tile = this._newTerminalTile(sessionId, entry.body); this._connectTile(sessionId); if (hadKeyboard) this._noteFocusedTile(entry.tile); }, /** * What the tile's body should say instead of a terminal, or null for none: a * session with no PTY attached (pid null) or a socket the server closed * because the session exited (4009), both of which Attach can start again; * or an agent that exited in a live pane (paneExit), which it cannot: the * attach and shell routes refuse while the pane's tmux client still runs * ("already has a running process"), and the single view has no restart for * it either, so the tile says so and points at Close session instead. Attach * was just pressed: nothing, while the server catches up. * * @returns {{text: string, attachable: boolean}|null} */ _tileAttachReason(sessionId, tile) { const session = this.sessions.get(sessionId); if (!session) return null; const pending = this._tileAttachPending?.get(sessionId); if (pending && Date.now() - pending < 15000) return null; const exited = typeof paneExitLabel === 'function' ? paneExitLabel(session.paneExit) : ''; if (exited) return { text: `The agent ${exited}`, attachable: false }; if (session.pid === null) return { text: 'Not attached', attachable: true }; if (tile?._stoppedCode === 4009) return { text: 'The session ended', attachable: true }; return null; }, /** * The Attach overlay over a tile's body (absolute, so the body and its xterm * keep their size): why there is no terminal, and an Attach button. */ _renderTileOverlay(sessionId) { const entry = this._tileGrid?.tiles.get(sessionId); if (!entry?.body) return; const session = this.sessions.get(sessionId); if (session && session.pid !== null && !session.paneExit) this._tileAttachPending?.delete(sessionId); const reason = this._tileAttachReason(sessionId, entry.tile); const busy = !!this._tileAttachInFlight?.has(sessionId); if (!reason && !busy) { if (entry.overlay) entry.overlay.hidden = true; return; } if (!entry.overlay) { const overlay = document.createElement('div'); overlay.className = 'tile-attach'; const text = document.createElement('span'); text.className = 'tile-attach-text'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tile-attach-btn'; btn.textContent = 'Attach'; btn.addEventListener('click', (e) => { e.stopPropagation(); void this.attachTileSession(sessionId); }); const hint = document.createElement('span'); hint.className = 'tile-attach-hint'; hint.textContent = 'It cannot be restarted in place: close it from \u22EF (Close session).'; overlay.append(text, btn, hint); entry.body.appendChild(overlay); entry.overlay = overlay; entry.overlayText = text; entry.overlayBtn = btn; entry.overlayHint = hint; } entry.overlay.hidden = false; const text = busy ? 'Attaching\u2026' : reason.text; // Compared with the last English text set, never the DOM: with the zh-CN // translator on, the DOM holds the translation and would never match, so // every refresh would rewrite English for it to translate again. if (entry.overlayLabel !== text) { entry.overlayLabel = text; entry.overlayText.textContent = text; } const attachable = busy || reason.attachable; entry.overlayBtn.hidden = !attachable; entry.overlayHint.hidden = attachable; entry.overlayBtn.disabled = busy; }, /** * Attach: starts the session's CLI in its pane, exactly as the single view's * automatic re-attach does: `POST /interactive` (or `/shell` for a shell) * with NO body, at most one in flight per session (the route has no guard of * its own). A session whose PTY-exit breaker tripped goes through the same * confirm the single view asks before `clearBreaker: true`; nothing automatic * ever sends that. On success the tile is remounted onto the new pane. */ async attachTileSession(sessionId) { const session = this.sessions.get(sessionId); if (!session) return false; // An agent that exited in a live pane cannot be started again in place. if (this._tileAttachReason(sessionId, this._tileFor(sessionId))?.attachable === false) return false; this._tileAttachInFlight ||= new Set(); if (this._tileAttachInFlight.has(sessionId)) return false; let url = `/api/sessions/${sessionId}/${session.mode === 'shell' ? 'shell' : 'interactive'}`; let init = { method: 'POST' }; if (session.respawnBlocked) { const label = session.name || 'Session'; if (!window.confirm(`${label} was stopped after crashing repeatedly. Restart it?`)) return false; url = `/api/sessions/${sessionId}/interactive`; init = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clearBreaker: true }), }; } this._tileAttachInFlight.add(sessionId); this._renderTileOverlay(sessionId); let ok = false; try { const res = await fetch(url, init); // The routes report a refusal in the envelope of a 200. const body = await res?.json?.().catch(() => null); ok = !!res?.ok && body?.success !== false; } catch { ok = false; } finally { this._tileAttachInFlight.delete(sessionId); } if (ok) { if (init.body) session.respawnBlocked = false; session.status = 'busy'; (this._tileAttachPending ||= new Map()).set(sessionId, Date.now()); this._remountTile(sessionId); } else { this.showToast?.('Could not attach the session', 'error'); } this._renderTileOverlay(sessionId); return ok; }, /** * `● [logo] name · model ..... ⋯ ⤢ ×`: the status dot (the six-state * classifier the tab rows and both home screens share), the harness logo, the * session name (double-click renames), the model it runs when known, the * session menu (the tab rail's own), zoom and remove-tile. Its * buttons stop pointerdown, so acting on a tile that is not focused does not * also focus it (and spend its idle alert). The rest of it is the handle that * moves the tile (_installTileMoveDrag). */ _buildTileHeader(sessionId) { const el = document.createElement('div'); el.className = 'tile-header'; const dot = document.createElement('span'); dot.className = 'tile-dot home-sessions-dot home-sessions-dot--idle'; dot.setAttribute('aria-hidden', 'true'); // The harness's logo: PR #532's `run-mode-dot ` slot, so the logos, // the skins and the plain dot for an id without one stay in styles.css. Its // tooltip and accessible name carry the harness and the model in full // (_paintSessionHarness). const harness = document.createElement('span'); harness.className = 'tile-harness run-mode-dot'; harness.setAttribute('role', 'img'); const title = document.createElement('span'); title.className = 'tile-title'; const name = document.createElement('span'); name.className = 'tile-name'; // A session literally named like a UI string ("Sessions") must not be translated. name.setAttribute('data-i18n-skip', ''); name.addEventListener('dblclick', (e) => { e.stopPropagation(); this.startTileRename(sessionId); }); // The model the session runs, when the server knows it: the name itself is // never translated (data-i18n-skip on the inner span), the tooltip on the // outer one may be. Hidden from screen readers: the logo's accessible name // already says it. const model = document.createElement('span'); model.className = 'tile-model'; model.setAttribute('aria-hidden', 'true'); model.hidden = true; const modelName = document.createElement('span'); modelName.setAttribute('data-i18n-skip', ''); model.appendChild(modelName); title.append(name, model); const actions = document.createElement('span'); actions.className = 'tile-actions'; const button = (cls, label, glyph, onClick) => { const b = document.createElement('button'); b.type = 'button'; b.className = `tile-btn ${cls}`; b.title = label; b.setAttribute('aria-label', label); b.textContent = glyph; b.addEventListener('pointerdown', (e) => e.stopPropagation()); b.addEventListener('click', (e) => { e.stopPropagation(); onClick(e); }); return b; }; const zoomBtn = button('tile-zoom', 'Zoom this tile', '\u2922', () => this.zoomTile(sessionId)); zoomBtn.setAttribute('aria-pressed', 'false'); actions.append( button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)), zoomBtn, // (No + here, owner decision 9: tiles are added from the Tiles button // and its count menu, Ctrl/Cmd+click, a dragged tab, a tab group or Run.) // 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', () => this.removeTile(sessionId)) ); el.append(dot, harness, title, actions); this._installTileMoveDrag(sessionId, el, actions); return { el, dot, harness, name, model, modelName, zoomBtn }; }, /** * The header moves its tile: dragged onto another tile the two trade places, * onto an empty cell it moves there and leaves its own cell empty * (dropSessionOnTile / dropSessionOnSlot, the path a dragged tab takes, * through the same capture-phase drop targets, _acceptTabDrops). A native drag, so Escape and a drop anywhere else are the * browser's own cancel: nothing moves, and dragend clears what the drag * painted. The drag carries a type of its own and never text, so no text * field or terminal, in this page or another application, can take it as * typing; and it is not `draggedTabId`, so the tab strip ignores it. * * The handle is the header's free area: a press on a button or the rename * input starts no drag. dragstart's target is the header whatever was * pressed, so where the press landed is noted in the capture phase, ahead of * the buttons' own stopPropagation. `draggable` is off while moving is * (_paintTileHandle: a zoomed grid, a single tile, a rename in progress). */ _installTileMoveDrag(sessionId, header, actions) { let pressedOnControl = false; header.addEventListener( 'pointerdown', (e) => { const target = e.target; pressedOnControl = !!actions.contains?.(target) || /^(INPUT|BUTTON)$/i.test(target?.tagName || ''); }, true ); header.addEventListener('dragstart', (e) => { const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null; if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) { e.preventDefault?.(); return; } this._draggedTileId = sessionId; entry.el.classList.add('tile--dragging'); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; // Firefox starts no drag without data. e.dataTransfer.setData('application/x-codeman-tile', sessionId); } }); header.addEventListener('dragend', () => this._endTileMoveDrag()); }, /** * A header drag is over (dropped, cancelled with Escape, dropped outside), * or its tile or the grid went mid-drag: forget it and clear what it * painted. The drop targets' highlight too: a cancelled drag does not * reliably send dragleave. */ _endTileMoveDrag() { this._draggedTileId = null; const grid = this._tileGrid; for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target'); for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target'); }, /** * The header as a handle: `draggable` while the tile can move (not while a * tile is zoomed, alone, or being renamed), and its tooltip, the state and * how long ("working 3m") plus, while it can move, that it drags. Diffs on * the last English text, never the DOM (translated in zh-CN; see * _renderTileOverlay). */ _paintTileHandle(entry) { const movable = this._tilesMovable(); const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n'); if (entry.headerLabel !== title) { entry.headerLabel = title; entry.header.title = title; } const draggable = movable && !entry.renaming ? 'true' : 'false'; if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable); }, /** * Refreshes one tile's header from the session: dot state, harness logo, * name, model, the hover label ("working 3m") and the `needs` border. Diffs on existing nodes only, * and cheap: it runs on every tab render (every status change). */ _renderTileHeader(sessionId) { const entry = this._tileGrid?.tiles.get(sessionId); const session = this.sessions.get(sessionId); if (!entry?.header || !session) return; const row = this._sidebarRichRow?.(sessionId, session) || null; const state = row?.state || 'idle'; const dotClass = `tile-dot home-sessions-dot home-sessions-dot--${row?.exited ? 'done' : state}`; if (entry.dot.className !== dotClass) entry.dot.className = dotClass; const since = row?.since?.at ? this._mobileOverviewStampText?.(row.since.at, 'for') : ''; entry.stateLabel = row ? [row.pill, since].filter(Boolean).join(' ') : ''; this._paintTileHandle(entry); // The input of a rename in progress has taken the name's place in the // header, so updating the detached name never touches what is being typed. // A rename still in flight shows as already done, as on the tab. const name = this._inlineRenamePending?.get(sessionId) || this.getSessionName?.(session) || session.name || 'Session'; if (entry.name.textContent !== name) entry.name.textContent = name; this._paintSessionHarness(entry, session, 'tile-harness'); // A permission prompt is visible across the room. entry.el.classList.toggle('tile--needs', state === 'needs'); this._renderTileOverlay(sessionId); }, /** Every tile's header (after a tab render, i.e. any session change). */ _renderTileChrome() { const grid = this._tileGrid; if (!grid?.open) return; for (const [id, entry] of grid.tiles) { this._renderTileHeader(id); // A pane that started after the tile connected (a session Run made // while the grid is open, an Attach from elsewhere) was spawned at the // server's default size: the tile's own resize went out before there // was a PTY to take it, and Run's resize measures the parked main // terminal (nothing). Keyed on the sessions map, so a handleInit after // an SSE drop is seen too; a render skipped during an inline tab rename // is caught up by the rename's own render. const pid = this.sessions.get(id)?.pid ?? null; if (pid !== null && pid !== entry.pid) entry.tile.paneStarted?.(); entry.pid = pid; } }, /** * Double-click on a tile's name: an input in its place, Enter or leaving it * renames through the tab rename's own write queue, Escape cancels. The * header refresh leaves the name alone meanwhile. */ startTileRename(sessionId) { const entry = this._tileGrid?.tiles.get(sessionId); const session = this.sessions.get(sessionId); if (!entry || !session || entry.renaming) return; entry.renaming = true; // No drag from the header while its input is in it (_paintTileHandle). this._paintTileHandle(entry); const input = document.createElement('input'); input.type = 'text'; input.className = 'tile-rename-input'; input.setAttribute('aria-label', 'Session name'); // A rename still in flight is the user's last word. input.value = this._inlineRenamePending?.get(sessionId) ?? session.name ?? ''; let settled = false; const finish = (commit) => { if (settled) return; settled = true; input.replaceWith(entry.name); entry.renaming = false; const value = input.value.trim(); if (commit && value && value !== session.name && this.sessions.has(sessionId)) { entry.name.textContent = value; void this._queueInlineSessionName?.(sessionId, value); } this._renderTileHeader(sessionId); }; input.addEventListener('pointerdown', (e) => e.stopPropagation()); // A click in the input is not the header's click, which would focus the // terminal away from it. input.addEventListener('click', (e) => e.stopPropagation()); input.addEventListener('keydown', (e) => { // Enter and Escape during an IME composition belong to the IME. if (e.isComposing || e.keyCode === 229) return; if (e.key === 'Enter') { e.preventDefault(); finish(true); } else if (e.key === 'Escape') { e.preventDefault(); finish(false); } }); input.addEventListener('blur', () => finish(true)); entry.name.replaceWith(input); input.focus(); input.select?.(); }, _connectTile(sessionId) { this._tileGrid?.tiles .get(sessionId) ?.tile.connect() .catch(() => { /* Best-effort, as the split's Pane B: live output arrives once the socket opens. */ }); }, /** * A tile's socket stopped for good. 4009 (the session exited) keeps the tile * with its "session ended" marker; 4003 (refused), 4004 (session gone) and * 4010 (another socket took over) remove it. */ _onTileExit(sessionId, tile, code) { if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return; // The session exited: the tile stays, with the Attach overlay over it. if (code === 4009) { this._renderTileOverlay(sessionId); return; } this.removeTile(sessionId); }, /** * Columns x rows for the current tile count, applied to the grid section. * The shape comes from the count alone; within it each tile sits in its * cell (grid.cells) and any cell may be empty. When the shape changes, the * cells follow fitTileCells (each tile keeps its row and column if all fit, * else the tiles pack in reading order). */ _applyTileLayout() { const grid = this._tileGrid; const section = this._tileGridSection(); const { cols, rows, fits } = this._tileShapeFor(grid.ids.length); grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows); if (grid.colFr.length !== cols) grid.colFr = new Array(cols).fill(1); if (grid.rowFr.length !== rows) grid.rowFr = new Array(rows).fill(1); grid.cols = cols; grid.rows = rows; // A window too small for the tiles' minimum size shows the focused tile // alone, with a hint; once it fits again the grid comes back. A zoom the // user chose is theirs: it stays until they lift it. if (!fits && !grid.zoomedId && grid.focusedId) { grid.zoomedId = grid.focusedId; grid.autoZoom = true; this.showToast?.(`The window is too small for ${grid.ids.length} tiles: showing the focused one`, 'info'); } else if (fits && grid.autoZoom) { grid.zoomedId = null; grid.autoZoom = false; } const zoomed = grid.zoomedId && grid.tiles.has(grid.zoomedId) ? grid.zoomedId : null; section.classList.toggle('tile-grid--zoomed', !!zoomed); this._syncTileZoom(zoomed); // Moving is off while a tile is zoomed or alone: the headers say so. for (const entry of grid.tiles.values()) this._paintTileHandle(entry); // Zoomed: one cell; the other tiles stay connected but hidden (CSS), so // they measure nothing and send no resize. Otherwise every tile is placed // explicitly in its cell, with a divider track between columns and between // rows, and every empty cell holds a drop slot. section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.colFr); section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.rowFr); const holes = []; grid.cells.forEach((id, k) => { if (!id) { holes.push(k); return; } const el = grid.tiles.get(id)?.el; if (!el) return; el.style.gridColumn = id === zoomed ? '1' : String(2 * (k % cols) + 1); el.style.gridRow = id === zoomed ? '1' : String(2 * Math.floor(k / cols) + 1); }); this._syncTileDividers(zoomed ? 0 : cols, zoomed ? 0 : rows); this._syncTileSlots(zoomed ? [] : holes, cols); this._persistTileGrid(); }, // Each tile's zoom state: the zoomed one is shown alone (CSS), and every ⤢ // button says what pressing it does next. _syncTileZoom(zoomed) { for (const [id, entry] of this._tileGrid.tiles) { const on = id === zoomed; entry.el.classList.toggle('tile--zoomed', on); const zoomBtn = entry.zoomBtn; if (!zoomBtn) continue; zoomBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); const label = on ? 'Restore the grid' : 'Zoom this tile'; // Against the last English label, never the DOM (translated in zh-CN). if (entry.zoomLabel !== label) { entry.zoomLabel = label; zoomBtn.title = label; zoomBtn.setAttribute('aria-label', label); } } }, // The empty cells of a layout that is not full (3 tiles in a 2x2, 5 in a // 3x2), wherever they are: drop targets for a tab (it joins there) and for a // tile (it moves there). Each slot knows its cell (`data-cell`). _syncTileSlots(holes, cols) { const grid = this._tileGrid; grid.slots ||= []; while (grid.slots.length > holes.length) grid.slots.pop().remove(); while (grid.slots.length < holes.length) { const slot = document.createElement('div'); slot.className = 'tile-slot'; slot.textContent = 'Drop a tab or a tile here'; this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId, Number(slot.dataset.cell))); this._tileGridSection().appendChild(slot); grid.slots.push(slot); } grid.slots.forEach((slot, i) => { const k = holes[i]; slot.dataset.cell = String(k); slot.style.gridColumn = String(2 * (k % cols) + 1); slot.style.gridRow = String(2 * Math.floor(k / cols) + 1); }); }, // One divider per gap between columns and between rows, created and dropped // as the counts change (never rebuilt while they stay, so a drag in progress // keeps its element). _syncTileDividers(cols, rows) { const grid = this._tileGrid; const section = this._tileGridSection(); const wanted = new Set(); for (let i = 0; i < cols - 1; i++) wanted.add(`col-${i}`); for (let i = 0; i < rows - 1; i++) wanted.add(`row-${i}`); for (const [key, el] of grid.dividers) { if (wanted.has(key)) continue; if (this._tileDividerDrag?.key === key) this._tileDividerDragTeardown?.(); el.remove(); grid.dividers.delete(key); } for (const key of wanted) { let el = grid.dividers.get(key); const [axis, n] = key.split('-'); const index = Number(n); if (!el) { el = document.createElement('div'); el.className = `tile-divider tile-divider--${axis}`; el.setAttribute('role', 'separator'); el.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal'); el.setAttribute('aria-label', axis === 'col' ? 'Resize tile columns' : 'Resize tile rows'); el.addEventListener('pointerdown', (e) => this._startTileDividerDrag(e, axis, index, el, key)); section.appendChild(el); grid.dividers.set(key, el); } el.style.gridColumn = axis === 'col' ? String(2 * index + 2) : '1 / -1'; el.style.gridRow = axis === 'col' ? '1 / -1' : String(2 * index + 2); } }, /** * Drags a column or row divider: the two tracks either side trade size, each * kept at the minimum tile size (dragTrackFractions, constants.js). The * affected tiles reflow locally once per animation frame; their PTYs hear * ONE resize each, at pointer-up, never per move (each one is a tmux resize * and a SIGWINCH). Pointer capture keeps the drag on the divider whatever is * under the pointer; closing the grid or removing a tile mid-drag tears it * down through _tileDividerDragTeardown. */ _startTileDividerDrag(e, axis, index, divider, key) { if (e.button !== undefined && e.button !== 0) return; const grid = this._tileGrid; if (!grid?.open) return; e.preventDefault?.(); e.stopPropagation?.(); this._tileDividerDragTeardown?.(); const T = window.CodemanTileGrid; const section = this._tileGridSection(); const rect = section.getBoundingClientRect(); const isCol = axis === 'col'; const count = isCol ? grid.cols : grid.rows; const total = (isCol ? rect.width : rect.height) - 2 * TILE_GRID_PADDING_PX - TILE_DIVIDER_PX * (count - 1); const startFr = (isCol ? grid.colFr : grid.rowFr).slice(); const start = isCol ? e.clientX : e.clientY; const minPx = isCol ? T.TILE_MIN_W : T.TILE_MIN_H; const affected = []; grid.cells.forEach((id, k) => { if (!id) return; const track = isCol ? k % grid.cols : Math.floor(k / grid.cols); if (track === index || track === index + 1) affected.push(grid.tiles.get(id).tile); }); let raf = null; let pending = start; let capturedPointerId = null; const apply = (pos) => { if (!grid.open) return; const fr = T.dragTrackFractions(startFr, index, pos - start, total, minPx); if (isCol) grid.colFr = fr; else grid.rowFr = fr; section.style[isCol ? 'gridTemplateColumns' : 'gridTemplateRows'] = tileGridTracks(fr); for (const tile of affected) tile.localFit(); }; const onMove = (ev) => { pending = isCol ? ev.clientX : ev.clientY; if (raf !== null) return; raf = requestAnimationFrame(() => { raf = null; apply(pending); }); }; const endDrag = () => { divider.classList.remove('dragging'); document.body.classList.remove('tile-grid-resizing', `tile-grid-resizing--${axis}`); if (capturedPointerId !== null) { try { divider.releasePointerCapture?.(capturedPointerId); } catch { /* Already released. */ } capturedPointerId = null; } divider.removeEventListener('pointermove', onMove); divider.removeEventListener('pointerup', onUp); divider.removeEventListener('pointercancel', onUp); if (raf !== null) { cancelAnimationFrame(raf); raf = null; } if (this._tileDividerDragTeardown === endDrag) { this._tileDividerDragTeardown = null; this._tileDividerDrag = null; } }; const onUp = () => { // The last queued frame carries the final pointer position. const queued = raf !== null; endDrag(); if (queued) apply(pending); for (const tile of affected) { if (!tile._destroyed) tile.fit(); } this._persistTileGrid(); }; divider.classList.add('dragging'); document.body.classList.add('tile-grid-resizing', `tile-grid-resizing--${axis}`); try { divider.setPointerCapture?.(e.pointerId); capturedPointerId = e.pointerId ?? null; } catch { /* The drag still works through the listeners below. */ } divider.addEventListener('pointermove', onMove); divider.addEventListener('pointerup', onUp); divider.addEventListener('pointercancel', onUp); this._tileDividerDragTeardown = endDrag; this._tileDividerDrag = { key }; }, /** * Zooms a tile to fill the grid, like tmux zoom, or restores the grid when it * is the one zoomed. A tile that is not focused is focused first (a human * selection: the user asked to look at it). Every tile is refitted after, the * shown ones to their new size and the zoomed one to the whole grid. */ zoomTile(sessionId) { const grid = this._tileGrid; if (!grid?.open || !grid.tiles.has(sessionId)) return; if (grid.zoomedId === sessionId) { grid.zoomedId = null; grid.autoZoom = false; } else { if (grid.focusedId !== sessionId) this.selectSession(sessionId); grid.zoomedId = sessionId; grid.autoZoom = false; } this._applyTileLayout(); this._scheduleTileGridRefit(); }, // Refits every tile once the grid area has settled: one xterm resize and one // PTY resize together per tile (#464), on the trailing edge. _scheduleTileGridRefit() { const grid = this._tileGrid; if (!grid?.open) return; clearTimeout(grid.refitTimer); grid.refitTimer = setTimeout(() => { grid.refitTimer = null; if (!grid.open) return; // The 3-tile layout depends on the width (3x1 or 2x2). this._applyTileLayout(); for (const { tile } of grid.tiles.values()) tile.fit(); }, TILE_GRID_REFIT_MS); }, // Narrowing the window past the desktop gate returns to the single view of the // focused session; the grid is remembered. _installTileGridWidthGate() { if (this._tileGridWidthGateInstalled || !window.matchMedia) return; this._tileGridWidthGateInstalled = true; const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`); mq.addEventListener('change', (e) => { if (!e.matches && this._tileGrid?.open) this.closeTileGrid({ keepStored: true, reselect: true }); }); }, _paintTileFocus() { const grid = this._tileGrid; for (const [id, { el }] of grid?.tiles || []) el.classList.toggle('focused', id === grid.focusedId); }, /** * Focuses a tiled session: the tile branch of selectSession. Moving focus is * an `activeSessionId` change plus `xterm.focus()`: no fetch, no replay. It * runs the panel refresh a normal switch runs and skips everything bound to * the main terminal (cleanup, replay, resize, its socket, local echo). Only a * USER-initiated selection (`auto` not true) acknowledges the idle alert. * * @param {string} sessionId * @param {{auto?: boolean, focus?: boolean}} [options] - `focus: false` leaves * DOM focus where it is (an app-driven reconcile must not steal it) */ _selectTiledSession(sessionId, options = {}) { const grid = this._tileGrid; const entry = grid?.open ? grid.tiles.get(sessionId) : null; if (!entry) return; const userInitiated = options.auto !== true; // Aborts any in-flight normal select at its next _isStaleSelect check. const selectGen = ++this._selectGeneration; this._hideWebviewLayer?.(); this.activeSessionId = sessionId; grid.focusedId = sessionId; // Moving focus off a zoomed tile restores the grid, as selecting another // pane does in tmux. An automatic zoom (the window cannot fit the tiles) // follows focus instead: there is no grid to restore. if (grid.zoomedId && grid.zoomedId !== sessionId) { grid.zoomedId = grid.autoZoom ? sessionId : null; this._applyTileLayout(); this._scheduleTileGridRefit(); } this._activateFileBrowserSession?.(sessionId); try { localStorage.setItem('codeman-active-session', sessionId); } catch { /* Per-device convenience only. */ } // The SSE subscription follows the focused session as in the single view; // its terminal frames are dropped by the parking guards. this._updateSseSubscription?.(sessionId); this.hideWelcome(); if (userInitiated) this.markIdleAlertSeen(sessionId); this._paintTileFocus(); this._updateActiveTabImmediate?.(sessionId); this.closeSessionSidebarOnHandheld?.(); this.renderSessionTabs?.(); const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`); // Not while it still glows: on every skin but OG the glow is `animation: // none`, so animationend never comes and each tile focus used to leave // another listener behind on the tab (49 after 50 focus changes). if (activeTab && !activeTab.classList.contains('tab-glow')) { activeTab.classList.add('tab-glow'); activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true }); } this.updateAttachmentHistoryBadge?.(); if (this.attachmentHistoryDrawerOpen) this.loadAttachmentHistory?.(sessionId); if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession(); this.refreshHostWakeBanner?.(sessionId); this.currentSessionWorkingDir = this.sessions.get(sessionId)?.workingDir || null; const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16); idleCb(() => this._refreshSessionPanels(sessionId, selectGen)); // The keyboard follows focus: shortcuts, voice and paste act on this tile. this._noteFocusedTile(entry.tile); if (options.focus !== false) entry.tile.terminal?.focus(); this._persistTileGrid(); }, // ── Persistence (codeman:tile-grid, per device, ids only) ──────────────── /** * Writes the open grid: ids, focus, a zoom the user chose (an automatic one * is worked out again from the window) and the divider fractions. Never * content. `open: false` is the closed-but-remembered state. Never in a solo * window; a storage failure only costs the convenience. */ _persistTileGrid({ open = true } = {}) { const grid = this._tileGrid; if (this.isSoloWindow || !grid || grid.ids.length === 0) return; if (open && !grid.open) return; const state = { v: 1, open, // The cells, null for an empty one, so a hole comes back where it was // (sanitizeTileGridState; a build before cells drops the nulls and reads // the tiles packed, as it always did). ids: grid.cells.slice(), focused: grid.focusedId, zoomed: grid.autoZoom ? null : grid.zoomedId, colFr: grid.colFr.slice(), rowFr: grid.rowFr.slice(), }; try { localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify(state)); } catch { /* Per-device convenience only. */ } }, _forgetStoredTileGrid() { try { localStorage.removeItem(TILE_GRID_STORAGE_KEY); } catch { /* Nothing stored. */ } }, /** The stored grid, sanitized against the sessions this page knows now, or null. */ _readStoredTileGrid() { if (this.isSoloWindow) return null; let raw = null; try { raw = localStorage.getItem(TILE_GRID_STORAGE_KEY); } catch { return null; } if (!raw) return null; return window.CodemanTileGrid.sanitizeTileGridState(raw, this.sessions, this.detachedSessions); }, /** * Opens a stored grid: its tiles and focus, then the fractions it had (only * if they still match the layout) and a zoom the user chose. `auto`: the app * is putting it back, so no idle alert is spent. `ids` is the set to open, * the stored one by default (a reload); the Tiles button passes it trimmed * or filled to the remembered count, a shape change the cell model's rule * handles (reformTileCells). */ _openStoredTileGrid(stored, ids = stored.ids) { const focus = stored.zoomed || stored.focused; // The stored set (an open split closes without joining it, decision 8, // case a), trimmed or filled to the count by the caller. if (!this.openTileGrid(ids, { focusedId: focus, auto: true, mergeSplit: false })) return false; const grid = this._tileGrid; const T = window.CodemanTileGrid; // openTileGrid packed the tiles from the first cell. The stored tiles go // back to their cells (a session gone since leaves its cell empty): as // they were when this is the stored set in the shape it was stored with, // otherwise by the cell model's rule, the tiles the count adds filling the // empty cells in reading order, holes first. const storedCells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null)); const kept = storedCells.filter(Boolean); const sameSet = ids.length === stored.ids.length && ids.every((id) => stored.ids.includes(id)); const cells = sameSet ? storedCells : T.reformTileCells( storedCells, T.tileCellCols(storedCells.length), kept, grid.ids.filter((id) => !kept.includes(id)), grid.cols, grid.rows ); if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) { grid.cells = cells; } // 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 // row count (the window may have changed the layout since). if (stored.colFr) grid.colFr = stored.colFr.slice(); if (stored.rowFr) grid.rowFr = stored.rowFr.slice(); if (stored.zoomed && grid.tiles.has(stored.zoomed)) { grid.zoomedId = stored.zoomed; grid.autoZoom = false; } this._applyTileLayout(); this._scheduleTileGridRefit(); return true; }, /** * Page load (handleInit, in place of selecting the session to restore): a * grid stored OPEN on this device comes back, ids sanitized against the * session list (deleted, detached and duplicate ids dropped). The main * terminal then never loads on this page load, so no `full=1` capture is * paid for a terminal about to be parked. Not in a solo window (nothing is * read there), nor on a window too narrow for the grid (openTileGrid * refuses; the stored grid waits for a wide one). * * @returns {boolean} whether the grid was restored */ _restoreTileGrid() { if (this._tilesOwnTerminal()) return false; const stored = this._readStoredTileGrid(); if (!stored?.open || stored.ids.length === 0) return false; return this._openStoredTileGrid(stored); }, /** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */ _closeStoredTileGrid() { const stored = this._readStoredTileGrid(); if (!stored?.open) return; // Stored as it was read, holes included (`ids` carries the cells). const { cells, ...rest } = stored; try { localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false })); } catch { /* Per-device convenience only. */ } }, /** Header connection state while tiles own the terminal: every live tile socket open, or not. */ _tileGridSocketState() { for (const { tile } of this._tileGrid?.tiles.values() || []) { // A tile stopped for good (its session exited) has nothing to reconnect. if (tile._stoppedCode !== null && tile._stoppedCode !== undefined) continue; if (!tile._wsReady) return 'reconnecting'; } return 'connected'; }, /** * handleInit (page state reloaded, or SSE back after a server restart) with * the grid open: tiles whose sessions are gone or popped out are removed, the * rest are KEPT (never rebuilt) and told to reconnect now instead of waiting * out their backoff, and focus stays on a live tile. * * @returns {boolean} whether the grid is still open (handleInit then skips * restoring the main terminal) */ _reconcileTileGrid() { const grid = this._tileGrid; if (!grid?.open) return false; for (const id of grid.ids.slice()) { if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false }); } if (!grid.open) return false; // Only a focus that is gone moves: re-selecting the same tile would hide an // active web tab on every SSE blip (_selectTiledSession hides the web layer), // which the single view's reconnect never does. if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true }); for (const { tile } of grid.tiles.values()) tile.reconnectNow(); return true; }, }); // A tiled session deleted (here or elsewhere) loses its tile; if it held focus, // the neighbouring tile takes it (`auto`: the app chose, so no idle alert is // spent). Done BEFORE the original handler, so activeSessionId no longer names // the deleted id and its welcome-screen handoff stays out of it. The last tile // closes the grid without a reselect, and the original handler then shows the // welcome screen as in the single view. A close started from this tab // (closeSession, in _closingSessions) owns its own follow-up: only the tile goes. const _tileGridOriginalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted; CodemanApp.prototype._onSessionDeleted = function (data) { const grid = this._tileGrid; if (grid?.has(data.id)) { const wasFocused = grid.focusedId === data.id; const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id); this.removeTile(data.id, { refocus: false }); if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) { this._selectTiledSession(neighbor, { auto: true }); } } return _tileGridOriginalOnSessionDeleted.call(this, data); }; // Every tab render (any session change: status, hooks, name) refreshes the // tile headers too, so a tile's dot, name and needs border follow the same // state the tab shows. const _tileGridOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate; CodemanApp.prototype._renderSessionTabsImmediate = function (...args) { const result = _tileGridOriginalRenderSessionTabsImmediate.apply(this, args); this._renderTileChrome?.(); return result; };