// 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 }`. `open: false` keeps it // remembered for one-click return; restored on reload inside handleInit. const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid'; // 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. 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' }, 'remove-tile': { needsOpen: true }, 'zoom-tile': { needsOpen: true }, }; /** The grid's state. `has(id)` answers only while it is open. */ class TileGridModel { constructor() { this.open = false; // Session ids in reading order (row-major). this.ids = []; // 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); } } 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.ids = []; 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); 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 in progress ends with the grid. this._tileDividerDragTeardown?.(); // 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.ids = []; 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 to choose which sessions)' : 'Tiles: show several sessions side by side (right-click to choose which)'; 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. The texts say 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, hint: byCap ? `Up to ${max} tiles` : `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`, full: byCap ? `The grid holds at most ${max} tiles` : `The grid already holds what this window fits (${capacity})`, }; }, /** * 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 grid takes are disabled. With the grid open, Open * replaces its set. */ openTilePicker(event) { // The right-click: the browser's own context menu stays away. event?.preventDefault?.(); if (this._tilePicker) { this.closeTilePicker(); return; } if (!this.canOpenTileGrid()) return; const T = window.CodemanTileGrid; const limit = this._tileGridLimit(); const capacity = limit.capacity; const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions); 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); const checked = new Set(seed.slice(0, capacity)); const menu = document.createElement('div'); menu.id = 'tilePickerMenu'; menu.className = 'tile-picker-menu'; menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', 'Show sessions as tiles'); const list = document.createElement('div'); list.className = 'tile-picker-list'; const boxes = []; for (const c of candidates) { const row = document.createElement('label'); row.className = 'tile-picker-item'; const box = document.createElement('input'); box.type = 'checkbox'; box.value = c.id; box.checked = checked.has(c.id); const name = document.createElement('span'); // A session literally named like a UI string must not be translated. name.setAttribute('data-i18n-skip', ''); name.textContent = c.label; row.append(box, name); list.appendChild(row); boxes.push(box); } const footer = document.createElement('div'); footer.className = 'tile-picker-footer'; const hint = document.createElement('span'); hint.className = 'tile-picker-hint'; hint.textContent = limit.hint; const open = document.createElement('button'); open.type = 'button'; open.className = 'tile-picker-open'; open.textContent = 'Open tiles'; footer.append(hint, open); if (candidates.length === 0) { const empty = document.createElement('div'); empty.className = 'tile-picker-empty'; empty.textContent = 'No sessions to show as tiles'; menu.appendChild(empty); } else { menu.append(list, footer); } const sync = () => { const count = boxes.filter((b) => b.checked).length; for (const b of boxes) { b.disabled = !b.checked && count >= capacity; b.title = b.disabled ? limit.full : ''; } open.disabled = count === 0; }; for (const b of boxes) b.addEventListener('change', sync); sync(); open.addEventListener('click', () => { const ids = boxes.filter((b) => b.checked).map((b) => b.value); this.closeTilePicker(); if (ids.length > 0) this._replaceTileGrid(ids); }); document.body.appendChild(menu); const btn = document.querySelector('.btn-tile-grid'); if (btn?.getBoundingClientRect) { // Under the Tiles button (.tile-picker-menu is position: fixed). const rect = btn.getBoundingClientRect(); menu.style.top = `${rect.bottom + 4}px`; menu.style.right = `${window.innerWidth - rect.right}px`; } // A click elsewhere or Escape closes it. A click on the Tiles button needs no // exception: its own handler (toggleTileGrid) closes the picker first. const onOutside = (e) => { if (menu.contains?.(e.target)) return; this.closeTilePicker(); }; const onKey = (e) => { if (e.key === 'Escape') this.closeTilePicker(); }; this._tilePicker = { menu, onOutside, onKey }; document.addEventListener('click', onOutside); document.addEventListener('keydown', onKey); (boxes.find((b) => !b.disabled) || open).focus?.(); }, /** Idempotent: the global Escape handler calls it whether or not the picker is open. */ closeTilePicker() { const picker = this._tilePicker; if (!picker) return; this._tilePicker = null; document.removeEventListener('click', picker.onOutside); document.removeEventListener('keydown', picker.onKey); picker.menu.remove(); }, /** * A 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, * zoom and remove chords apply only while the grid is open, however it was * opened. 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.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); }, /** * 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 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); 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. */ focusTileInDirection(direction) { const grid = this._tileGrid; if (!grid?.open) return; const id = window.CodemanTileGrid.tileInDirection(grid.ids, 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). Returns whether it was added. */ addTile(sessionId) { 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; // 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; }, /** * Removes one tile; the session keeps running. 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. this._tileDividerDragTeardown?.(); // 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.ids.splice(grid.ids.indexOf(sessionId), 1); // 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). pointerdown, not // click, so focus moves before the press reaches xterm, and never // preventDefault: xterm's own mousedown focuses its textarea and starts // selections. el.addEventListener('pointerdown', () => { if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); }); this._acceptTabDrops(el, (draggedId) => this.dropSessionOnTile(draggedId, 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, name: header.name, 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, }); grid.ids.push(sessionId); 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`). Capture phase, with the event * stopped: the drag carries the session id as text, and xterm's helper * textarea would otherwise accept that drop and type the id into a PTY. Any * other drag (a file) is left alone. */ _acceptTabDrops(el, onDrop) { el.addEventListener( 'dragover', (e) => { if (!this.draggedTabId || !this._tileGrid?.open) return; e.preventDefault?.(); e.stopPropagation?.(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; 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'); if (!this.draggedTabId || !this._tileGrid?.open) return; e.preventDefault?.(); e.stopPropagation?.(); onDrop(this.draggedTabId); }, true ); }, /** * A tab dropped on a tile: a session not yet tiled REPLACES that tile (same * place; the replaced session keeps running); one already tiled swaps places * with it. 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)) { const a = grid.ids.indexOf(draggedId); const b = grid.ids.indexOf(targetId); grid.ids[a] = targetId; grid.ids[b] = draggedId; this._applyTileLayout(); this._scheduleTileGridRefit(); } else { this._tileDividerDragTeardown?.(); const index = grid.ids.indexOf(targetId); if (!this._mountTile(draggedId)) return; // _mountTile appended it; it takes the replaced tile's place instead. grid.ids.pop(); grid.ids.splice(index, 1, 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); }, /** A tab dropped on an empty slot joins the grid there (an already tiled one moves there). */ dropSessionOnSlot(draggedId) { const grid = this._tileGrid; if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; if (grid.tiles.has(draggedId)) { // Empty slots are always the last cells in reading order. grid.ids.splice(grid.ids.indexOf(draggedId), 1); grid.ids.push(draggedId); this._applyTileLayout(); this._scheduleTileGridRefit(); } else if (!this.addTile(draggedId)) { 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 * on what the Tiles toggle would bring back, plus this session. 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 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; }, /** * "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 (the picker's Open, * "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; }, /** * `● name ......... ⋯ ⤢ ×`: the status dot (the six-state classifier the tab * rows and both home screens share), the session name (double-click * renames), 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). */ _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'); 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); }); 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's // picker, 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, name, actions); return { el, dot, name, zoomBtn }; }, /** * Refreshes one tile's header from the session: dot state, name, 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') : ''; const label = row ? [row.pill, since].filter(Boolean).join(' ') : ''; // Against the last English label, never the DOM (translated in zh-CN; see _renderTileOverlay). if (entry.headerLabel !== label) { entry.headerLabel = label; entry.header.title = label; } // 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; // 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; 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()); 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. */ _applyTileLayout() { const grid = this._tileGrid; const section = this._tileGridSection(); const rect = section.getBoundingClientRect?.() || { width: 0, height: 0 }; const { cols, rows, fits } = window.CodemanTileGrid.computeTileLayout({ count: grid.ids.length, width: rect.width || window.innerWidth, height: rect.height || window.innerHeight, }); 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); // 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 reading order, with a divider track between columns and // between rows. section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.colFr); section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.rowFr); grid.ids.forEach((id, k) => { 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 ? 0 : cols * rows - grid.ids.length, 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): drop targets for a tab, after the tiles in reading order. _syncTileSlots(count, cols) { const grid = this._tileGrid; grid.slots ||= []; while (grid.slots.length > count) grid.slots.pop().remove(); while (grid.slots.length < count) { const slot = document.createElement('div'); slot.className = 'tile-slot'; slot.textContent = 'Drop a tab here'; this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId)); this._tileGridSection().appendChild(slot); grid.slots.push(slot); } grid.slots.forEach((slot, i) => { const k = grid.ids.length + i; 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.ids.forEach((id, k) => { 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, ids: grid.ids.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. */ _openStoredTileGrid(stored) { const focus = stored.zoomed || stored.focused; // 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 // 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; try { localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...stored, 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; };