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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 23:23:55 +02:00

55 KiB
Raw Blame History

Tile Grid: Design Spec

Status: PR 1 (tile foundation) implemented on feat/terminal-tile; PR 2 (the grid) implemented on feat/tile-grid, both local only. Builds on docs/split-pane-sessions-plan.md; the split pane stays. Author: Claude (planning session with the maintainer), 2026-10-06 Branches: PR 1 feat/terminal-tile, PR 2 feat/tile-grid stacked on it (worktrees claudeman-tiles, claudeman-tilegrid) Scope: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.

As built: where PR 2 differs from this spec

The design below stands; these are the places the built grid deliberately went another way, or settled a question the spec left open. The invariants as built are in docs/architecture-invariants.md#tile-grid.

  • An agent that exited in a live pane (paneExit) gets no Attach button. Both attach routes (/interactive, /shell) refuse while the pane's tmux client still runs ("Session already has a running process") and report that in the envelope of a 200, so the edge-case row below cannot work without a server change. The tile shows the exit and points at Close session. A session with no PTY (pid === null) and a socket closed with 4009 do get Attach. Restarting an exited agent in place is a follow-up.
  • Ctrl+Shift+G follows showTileGridButton (decided by the owner, decision 6): with the setting off the toggle chord is inert. A grid opened another way (Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords.
  • Dividers are grid tracks. Each gap between columns and rows is its own 6px track (the grid gap is 0) and tiles are placed explicitly in reading order, which is also what the empty-slot drop targets need. Fractions reset when the column or row count changes.
  • Zoom follows tmux. Moving focus to another tile restores the grid; an automatic zoom (window too small for the minimum tile) follows focus instead.
  • Tile loads are bounded (boundedLoad), carry a fetch deadline covering the body (Pane B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps its last frame.
  • 4009 lands on the Attach overlay, and 4003/4004/4010 remove the tile.
  • The Tiles button opens the grid at once (owner decision 8): a click (and Ctrl+Shift+G, the same toggleTileGrid) opens tileGridOpenSet (constants.js): the grid this tab last had, else an open split's two sessions, else the open sessions in tab order up to what the grid takes here (the cap, or fewer when the window fits fewer), the active one always included and focused. A remembered grid wins over an open split: the split closes and its sessions do not join (the owner's order, read literally). Right-click opens the picker; with the grid open it shows the current tiles and Open replaces them. Ctrl/Cmd+click on a tab with the grid closed opens the same set plus that session.
  • The grid holds at most 6 tiles (owner decision 7). TILE_GRID_MAX in constants.js is the one cap every limit reads; the layout table keeps 7 to 9 (TILE_LAYOUT_MAX), unreachable, so going back to nine is that one line. A stored grid with more ids comes back as its first six. Where this spec says nine, read six.
  • "+ / New session in this case" runs the normal Run for that case and joins through the same auto-join as any Run from this tab.
  • A tile that joins before its pane exists resends its size when the pid appears (TerminalTile.paneStarted()): the server drops a resize for a session with no PTY and spawns at 120x40, and Run's own resize measures the parked main terminal. Applying a pre-spawn resize at spawn time on the server would make this unnecessary (follow-up).

Problem

Codeman's terminal area shows exactly one session at a time. The split pane (showSplitButton, src/web/public/terminal-split.js) added a second live session beside the active one, but stops at two, gives the second pane ("Pane B") a deliberately reduced feature set, and has no reconnect: every Codeman restart (every release deploy) leaves Pane B dead until the split is closed and reopened.

The goal is a dashboard of agents: four, six or nine live Claude sessions on one monitor, each readable and typeable, with its state visible at a glance. The target picture is a 3x2 grid of tiles, each tile a full terminal with a small header: status dot, session name, a ⋯ menu, maximize, + and ×.

Goal (v1)

  • A tile grid of 1 to 9 live sessions in one Codeman window, laid out automatically by count, with draggable column and row dividers.
  • Every tile is equal: same terminal class, same features, same header. One tile is focused and receives the keyboard.
  • The rest of the app follows the focused tile: files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste.
  • Tiles survive a Codeman restart (reconnect) and a page reload (per-device persistence).
  • The split pane stays as it is for now (decided). The grid is a separate mode that shares the same tile class with it; the two are never open at the same time (see "Coexistence with the split pane").

Non-goals (v1)

  • Phones and tablets. The grid is desktop-only, gated at 1180 px like the split (SPLIT_PANE_MIN_WIDTH) and the home rail (HOME_SESSIONS_MIN_WIDTH).
  • More than 9 tiles.
  • WebGL rendering inside tiles (see "Rendering" below).
  • Full parity with the main terminal's touch and IME features: local-echo overlay, the CJK input textarea, the keyboard accessory bar, touch gestures, mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full history" banner. These exist for touch devices or rare cases; a desktop keyboard user types straight into xterm, which is how Codeman behaved before those features existed.
  • Server-side persistence of grids (named presets per owner).
  • Pop-out windows (/session/:id, solo mode) showing a grid.

Current architecture (why this is not a CSS change)

terminal-ui.js is built around ONE terminal: this.terminal (one xterm), this._ws / this._wsSessionId (one WebSocket, rebound on every tab switch via _disconnectWs() + _connectWs(id)), this._xtermSnapshots (scrollback snapshots restored into that one terminal), and about 280 references to that singleton across input handling, sizing, link providers, local echo, IME, touch handling and the keyboard accessory bar.

The split pane works around it with a second, independent object, SplitTerminalPane: its own xterm, its own fit addon, its own /ws/sessions/:id/terminal socket. That class is the seed of this feature. It already handles, and this design keeps:

  • single-flight buffer loads (_loadBuffer / _refreshBuffer / _endBufferLoad), so two replays never interleave;
  • the "disconnected" marker that must be the LAST thing on screen (_markerOwed, _stampMarkerIfOwed);
  • the bounded scroll-to-top history pull for shell sessions (_pullHistory);
  • its own key handler gating app chords (palette, Alt nav, Ctrl+Z, Shift/Ctrl+Enter via send-key, smart copy);
  • divider drag with pointer capture, rAF-coalesced local fit, one PTY resize at pointer-up, and teardown when the split collapses mid-drag.

Key decision: equal tiles, main terminal parked

Three ways to put N sessions on screen were evaluated:

Approach How Verdict
A. Anchor plus light tiles The main terminal stays as tile 1; other tiles are Pane-B style Rejected. Tile 1 is privileged. The panels follow tile 1, not the tile you are typing in. Making another tile the "main" one costs two buffer reloads per click.
B. Equal tiles, main parked Every tile is a TerminalTile. The main terminal is hidden and disconnected while the grid is open. activeSessionId always equals the focused tile's session. Chosen
C. iframes of solo windows Each tile is /session/:id in an iframe Rejected. All frames share codeman:clientId (localStorage), and SseStreamManager.addClient evicts the previous stream with the same clientId, so frames knock each other's SSE offline about every 45 s (the staleness watchdog reconnects, evicting the next one). They also share codeman:pendingInput, load N full copies of the app, and play N notification sounds.

Why B:

  • Focus changes are instant. Moving focus is xterm.focus() plus an activeSessionId update. No fetch, no replay, no flicker.
  • Panels follow focus for free. Everything keyed on activeSessionId (files panel, git status poll, respawn and Ralph panels, subagent window visibility, voice, image upload, kill/close, tab highlight) follows the focused tile once the tile branch of selectSession runs the same panel refresh as a normal switch.
  • Hiding the main terminal is already proven safe. Web tabs set display:none on .terminal-wrap today (.main.webview-active). With the container hidden, FitAddon's proposeDimensions() reads auto widths and returns NaN, clampTerminalDimensions returns null, and no resize is sent.
  • The end state is clean. Long term, the main terminal can itself become a 1x1 grid of the same class, which deletes the singleton. B moves toward that; A entrenches it.

The cost of B is that tiles must reach desktop parity with the main terminal on the features that matter at a desk: reliable input, reconnect, file-path links, copy, image paste, voice. Those are listed under "Seams". Most of that work also fixes gaps the split pane has today.

User-facing behavior

Entry points

  • Header Tiles button (its own button, beside Split). As built (decision 8) a click opens the grid at once, the same as the toggle shortcut; the picker with checkboxes over open sessions, ordered like the tab strip, is on right-click. When the grid is open, a click closes it.
  • Ctrl/Cmd+click a tab: add that session to the grid (opens the grid if closed).
  • Drag a tab from the strip onto a tile to replace it, or onto an empty slot to add it.
  • "Open group as tiles" in the tab-group menu (vertical rail, where group menus exist).
  • New sessions started from this browser tab's Run button while the grid is open join the next free slot and take focus. Sessions created elsewhere (agents, other devices, cron) do not join.
  • Toggle shortcut (registry action toggleTileGrid).

Layout

Automatic by tile count, computed by a pure helper:

Tiles Layout
1 1x1
2 2x1
3 3x1 if the grid area is at least ~1800 px wide, else 2x2 with one empty slot
4 2x2
5-6 3x2
7-9 3x3

Hard cap 9. Capacity is also bounded by a minimum tile size (about 480x240 px, roughly 60 columns at the default tile font), so the picker disables additions the window cannot fit.

Column and row dividers are draggable (generalizing the split divider): the grid stores track fractions (grid-template-columns: <a>fr <b>fr …), each drag clamps both neighbors to the minimum tile size, reflows locally per animation frame, and sends one resize per affected tile at pointer-up.

Tile header

● name ……… ⋯ ⤢ + ×

  • ● status dot from the existing six-state classifier (app._sidebarRichRow(id, session), built on _mobileOverviewState): needs, error, waiting, working, idle, done, styled with the existing unscoped .home-sessions-dot--* classes. A needs tile also gets a pulsing red border so a permission prompt is visible across the room. The label ("working 3m") shows on hover via _mobileOverviewSince / _mobileOverviewStampText.
  • name via textContent with data-i18n-skip (a session literally named "Sessions" must not be translated). Double-click renames through _queueInlineSessionName(id, name).
  • ⋯ reuses openTabRailActionMenu(event, id) (tab-rail-resize.js): Session options, Open in a new window, Close session.
  • ⤢ zooms the tile to fill the grid, like tmux zoom. The other tiles stay connected but hidden; hidden tiles measure NaN and send no resizes. Pressing it again (or the shortcut) restores the grid.
  • + adds a session: a picker of open sessions not yet tiled, plus "New session in this case", which runs the normal quick-start for the tile's case and drops the result into the next slot.
  • × removes the tile ONLY. The session keeps running. Killing stays behind ⋯ → Close session and its existing confirm modal (requestCloseSession).

Focus and keyboard

  • The focused tile gets an accent border; its session is activeSessionId.

  • Tabs of tiled sessions carry an .in-tiles marker; the focused one is .active as usual.

  • Clicking a tile focuses it (a human selection, see "Focus and alert rules").

  • New registry actions (all rebindable, all swallowed in every xterm key handler so the chord never reaches a PTY):

    • toggleTileGrid (proposed Ctrl+Shift+G)
    • focusTileLeft/Right/Up/Down (proposed Alt+Shift+Arrows)
    • zoomTile (proposed Alt+Shift+Enter)
    • removeTile (unbound by default)

    The defaults must be checked against xterm passthrough, Claude Code's own bindings and browser chords before they are fixed.

  • While the grid is open, Ctrl+Tab and Alt+[ / Alt+] cycle through tiles.

  • A USER-initiated selection of a session that is NOT tiled (clicking its tab, Alt+1-9 onto it, the command palette) leaves the grid and shows that session in the normal single view; the grid is remembered and one click on Tiles brings it back (decision 1). An app-driven selection (auto: true) never collapses the grid; see "Selections while the grid is open".

  • Ctrl+L clears the focused tile; Ctrl+W is delete-word in the focused tile (it is not an app shortcut, decision 5); Ctrl +/- changes the tile font size; Ctrl+Shift+R restores the focused tile's size.

Persistence

Decided: per device, restored on reload. Stored in localStorage key codeman:tile-grid:

{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
  "colFr": [1, 1, 1], "rowFr": [1, 1] }

Ids only, never content. A pure sanitizer drops unknown, deleted, detached and duplicate ids on load. Never restored in a solo window.

The restore runs INSIDE handleInit, in place of its initial selectSession(restoreId, { auto: true }) (the non-keepTerminal branch), not after it. Otherwise the page first selects the active session in the main terminal, whose first non-shell select per page pulls an unbounded ?full=1 capture, only to park that terminal a moment later. With a stored open grid, the main terminal never loads on that page load. A later handleInit (SSE reconnect after a server restart, the keepTerminal branch) reconciles ids against the live list without rebuilding tiles that are still alive.

Gating

  • Setting showTileGridButton, per device (in displayKeys, stripped from the settings PUT, NOT in SettingsUpdateSchema), default OFF. Independent of showSplitButton, which is unchanged; a desk can show both buttons.
  • Hidden below 1180 px by both a JS width check with a matchMedia listener and a CSS @media (max-width: 1179px) backstop, exactly like the split button. Narrowing the window while the grid is open returns to the single view and keeps the stored grid.
  • Hidden in solo windows (body.solo-mode).
  • test/mobile-header-buttons-policy.test.ts keeps it off phones.
  • The toggle chord follows the setting (decision 6): with showTileGridButton off, Ctrl+Shift+G is inert and reaches the terminal like any unbound key; on, it toggles the grid. A grid opened another way keeps all its chords.

Components

1. TerminalTile (terminal-tile.js, load order 7.4, PR 1)

The SplitTerminalPane class moves out of terminal-split.js into a new src/web/public/terminal-tile.js and is renamed TerminalTile, keeping every behavior listed under "Current architecture". The split orchestration stays in terminal-split.js and constructs a TerminalTile for Pane B; the grid (PR 2) constructs one per tile. New in the class:

Reconnect. The primary pane's backoff ladder (CodemanWsReconnect, constants.js: 0, 250 ms, 500 ms, ... capped at 10 s) plus up to 250 ms of jitter, the attempt count reset only by a successful open. A reconnect is also kicked when SSE handleInit reports the server is back. After every reopen the tile runs a bounded refresh (the same in-stream \x1bc clear plus replay as _refreshBuffer), because output frames carry no sequence number and a gap cannot be replayed otherwise. That refresh goes through the grid's load queue like every other load (see "Load cost"): after a deploy restart all N tiles reopen within the same second, and N unqueued refreshes are exactly the capture storm the queue exists to prevent. Close codes that must NOT reconnect:

Code Meaning Tile does Owner decides (via onExit)
4009 Session exited Stops reconnecting, reports the code PR 1 split: an "exited" marker in Pane B (the split's existing delete path collapses it if the session is removed). PR 2 grid: the Attach overlay
4003 / 4004 Forbidden / session gone Stops reconnecting, reports the code (4003 is stopped by the tile itself: CodemanWsReconnect classes it as transient) PR 1 split: a marker saying why. PR 2 grid: removes the tile
4010 Superseded by a socket with the same cid Stops, but only for the CURRENT socket (see below) Same marker as 4003

The class never decides what happens to its container; it reports the close code through the onExit callback and the owner (split or grid) acts.

Replacing a socket is race-free. A reconnect can be kicked (by handleInit) while the old socket still looks open on the client: a half-open connection whose onclose has not fired yet. The new socket carries the same cid, so the server supersedes the old one with a 4010, and that late onclose would run the "4010: stop" branch on a perfectly healthy tile. So before opening a replacement the tile detaches the old socket's handlers (as destroy() already does: null onopen/onmessage/onclose/onerror, then close()), and every handler checks event.target === this.ws and ignores events from any socket that is no longer current.

The marker text becomes [disconnected, reconnecting…] (a stop writes [disconnected: <why>], TerminalTile.STOP_MARKERS) and keeps its "last thing on screen" rule. On reopen the closed state is cleared BEFORE the gap refresh, or the refresh re-owes the marker and stamps it under a healthy pane. reconnectNow() skips the backoff and never replaces an open socket.

Client id on the upgrade URL. cid=${clientId}:${tabNonce}:tile. The connection registry supersedes by cid PER SESSION, so a distinct suffix means a tile can never evict the main terminal's socket in a 4010 loop even if the same session were ever on both. Input frames keep the BARE clientId (that is what the server dedups on).

Reliable input. terminal.onData calls app._sendInputAsync(this.sessionId, data), which rides the tile's own socket through the input-socket map (see "Seams"). This gives the tile the same exactly-once delivery as the main terminal (per-session seq, ACKed, persisted until ACK), and through _ackDelivery it clears the session's idle alert when you type, which Pane B never did.

Geometry (rules from #464, see docs/architecture-invariants.md):

  • One method, syncGeometry(), measures (proposeDimensions()), resizes the xterm and sends {t:'z', c, r, v:'desktop'} with THE SAME raw dimensions. The xterm and the PTY never disagree. There is deliberately NO 40x10 floor (unlike the main terminal's clampTerminalDimensions): commit 57406f6c removed it from Pane B because the split's 20% divider clamp can leave Pane B at about 240 px, roughly 28 columns, and a floored xterm is wider than its container and clips columns. The server's own range (columns 1-500, rows 1-200) is the only bound. Grid tiles never get that narrow anyway (the minimum tile size keeps them near 60 columns).
  • Unchanged dimensions are not resent (Pane B had no such dedupe and fanned out a tmux resize-window per animation frame during drags before the rAF fix).
  • The {t:'zc'} reply is handled: adopt the PTY's COLUMNS only, keep local rows, via the pure reconcilePtyGeometry (constants.js). Pane B ignores zc today.
  • A hidden tile (zoomed out, web tab active) measures NaN and does nothing; it syncs when shown again.
  • Detached sessions are never tiled, so the split's detachedSessions yield in _sendResize becomes a removal (see "Edge cases").

Rendering. DOM renderer, no WebGL addon, as Pane B does today. Chrome allows roughly 16 live WebGL contexts per page and the main terminal keeps one. Measure nine busy tiles on the DOM renderer before considering WebGL (follow-up).

Scrollback. Tiles use their own cap, TILE_SCROLLBACK (proposed 10,000 lines), not DEFAULT_SCROLLBACK (50,000). Nine DOM-rendered xterms at 50k lines each is a real memory cost, and a tile's initial load is already bounded to a 1 MiB window, so a larger buffer only fills with live output over time. The shell history pull's "pane full" check reads term.options.scrollback, so it adapts to the lower cap unchanged.

Key handler. Pane B's attachCustomKeyEventHandler stays in TerminalTile (every tile IS a TerminalTile, so no separate factory is needed), plus:

  • Ctrl+V routes into the image-paste trap with this tile's terminal and session;
  • the new tile chords are swallowed (return false) so they never reach the PTY.

Links and copy. The tile registers the file-path link provider and uses the shared copy helper (see "Seams"), so clicking a path printed in a tile opens the file preview for THAT session.

Disposal. destroy() closes the socket (handlers nulled first, as today), unregisters from the input-socket map, removes listeners and disposes the xterm. Nothing may outlive a removed tile (24-hour sessions rule).

2. TileGrid controller and layout helper

  • computeTileLayout({ count, width, height, minTileW, minTileH }) and tileGridCapacity(...): pure, in constants.js, exported on window.CodemanTileGrid beside the existing helper namespaces. sanitizeTileGridState(raw, liveSessions, detachedIds): pure, same place.
  • The controller (in a new src/web/public/tile-grid.js, load order 7.6, as CodemanApp.prototype methods like the split code) owns: the ordered tile list, focusedId, zoomedId, track fractions, the <section class="tile-grid"> element, one ResizeObserver on that section (the main terminal's observer watches a hidden node and stops firing), the divider drags and the load queue.
  • DOM: the grid is a NEW sibling of .terminal-wrap inside .main, toggled by .main.tiles-active. Unlike the split there is no reparenting of .terminal-wrap. CSS adds .main.webview-active .tile-grid { display: none } beside the existing .terminal-split-container rule.

3. Parking the main terminal

Enter:

  1. _cleanupPreviousSession() once. Its snapshot of the current session is correct at that moment, and it disconnects the main socket and flushes local echo.
  2. Add .main.tiles-active: .terminal-wrap hidden, .tile-grid shown.
  3. hideWelcome().

Guards. With the main socket closed, _wsReady is false and the SSE terminal fallback would start writing the focused session's output into the hidden xterm (every handler keys on activeSessionId). One predicate, _tilesOwnTerminal(), turns these into no-ops while the grid is open:

  • _onSessionTerminal, _onSessionClearTerminal, _onSessionNeedsRefresh (it would fetch ?full=1 for nothing), _scheduleDroppedOutputRecovery;
  • the terminal.writeln calls in _onSessionCompletion and _onSessionError;
  • retryConnection and the keepTerminal branch of handleInit, which would reconnect the main socket;
  • as a backstop, beside the existing detachedSessions checks in sendResize, throttledResize, _maybeRefetchFullHistory and restoreTerminalSize;
  • the WebGL long-task guard (_installWebGLLongTaskGuard). Its PerformanceObserver watches the WHOLE page and counts every long task while the main terminal's WebGL addon exists, so long tasks caused by tile rendering or tile replays would trip it and write the sticky codeman-webgl-disabled marker (7 days), silently moving the main terminal to the DOM renderer for reasons that have nothing to do with WebGL. While tiles own the terminal the observer callback must not count entries.

A missed guard is mostly harmless (exit replays from scratch) but costs fetches and CPU, so the guard test enumerates them.

_computeConnectionDescriptor must derive the header connection state from the tile sockets while the grid is open (all open: connected; any reconnecting: degraded), or the header shows "Connecting" forever.

The SSE subscription stays [activeSessionId] as in single view. Those frames are dropped by the guards. (An empty list means "all sessions", so there is no "none" to subscribe to; this matches today's single-view duplication anyway.)

Exit:

  1. Destroy every tile, remove .main.tiles-active.
  2. this._lastResizeDims = null (as _redock does).
  3. Invalidate the main terminal's cached content for EVERY tiled id, not just the focused one: the _xtermSnapshots entry, the codeman-xs-<id> localStorage key and the buffer-cache entry. Those were written before the grid opened, possibly hours earlier, and selectSession paints a snapshot as a first frame before its fetch replaces it, so the next switch to a formerly tiled session would flash content from before the grid.
  4. selectSession(focusedId, { forceReload: true, auto: true }). For the already-active id that path drops the stale snapshot and nulls activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved, then reconnects and replays normally.

4. The tile branch of selectSession

Placed in app.js directly after the "already active" early return (~7876), so tapping the focused tab still acknowledges its alert and the detached-window check (~7855) still runs first:

if (this._tileGrid?.open) {
  if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options);
  // Decision 1: only a USER-initiated pick (or an explicit leaveTiles) of a
  // non-tiled session leaves the grid. An app-driven one never collapses it.
  if (options.auto === true && !options.leaveTiles) return;
  this.closeTileGrid({ keepStored: true });
}

_selectTiledSession keeps:

  • ++this._selectGeneration (aborts any in-flight normal select at its next _isStaleSelect check);
  • _hideWebviewLayer(); activeSessionId = id; _activateFileBrowserSession; the codeman-active-session key; hideWelcome();
  • markIdleAlertSeen(id) only when user-initiated (options.auto !== true);
  • _updateActiveTabImmediate, tab glow, renderSessionTabs, closeSessionSidebarOnHandheld;
  • updateAttachmentHistoryBadge, KeyboardAccessoryBar.refreshForActiveSession, refreshHostWakeBanner, currentSessionWorkingDir;
  • the deferred panel block (respawn banner and countdown, action log, task panel, Ralph state, CLI info, project insights, subagent window visibility, file browser). That block (~8452-8523) is first extracted into _refreshSessionPanels(id, generation) so both paths share one copy;
  • focusing the tile's xterm.

It skips everything bound to the main terminal: terminal.focus(), _cleanupPreviousSession, the truncation banner, playTerminalEntrance, local-echo state, _beginBufferLoad, syncTerminalGeometry, both sendResize calls, snapshot/cache/fetch replay, _fullHistoryLoaded, _markTerminalBufferReconciled, _connectWs, scroll-to-bottom, the resize retry, and the pid === null attach POST (the tile's Attach overlay owns it).

The split's own selectSession and _onSessionDeleted wrappers stay. They act only while this._splitPane is set, and the grid never opens alongside a split (see "Coexistence with the split pane"), so the two never compete.

Selections while the grid is open

Several app-driven paths call selectSession on their own, and without the auto rule above each of them would land on a non-tiled session and collapse the grid. Each one gets an explicit grid-aware behavior:

Path Today With the grid open
Close session on the focused tile (closeSession, from its menu or a user-bound key) Reads wasActive before its await, adds the id to _closingSessions, then selects the first remaining sessionOrder entry with auto: true, which is often NOT tiled A grid-aware fallback picker: remove the tile, then focus the neighboring tile (next in grid order, else previous). Only with no tiles left does it fall back to the sessionOrder pick, which closes the grid. Note the split's _onSessionDeleted wrapper deliberately skips selection for ids in _closingSessions, so the fallback MUST live in closeSession itself, not in the delete wrapper.
Session deleted elsewhere (_onSessionDeleted) The handoff selects the first remaining sessionOrder entry If it was tiled: remove the tile and focus a neighbor with auto: true. If it was not tiled it was not active, so there is no handoff.
Boot restore (handleInit) selectSession(restoreId, { auto: true }) Replaced by the grid restore when a stored grid is open (see "Persistence")
URL #session=<id> link selectSession(id, { auto: true }) Following a link is navigation, so this path passes leaveTiles: true: a tiled id focuses its tile, a non-tiled id opens the single view (grid kept in storage)
Pane promotion after a delete (split) selectSession(promoted, { auto: true }) Unchanged for the split; unreachable while the grid is open, since no split can be open then
Auto-join of a session created by this tab's Run Run selects the new session The session joins the grid first, then is selected through the tile branch

5. Focus and alert rules

These follow the Approvals Inbox acknowledgement rule (docs/architecture-invariants.md#approvals-inbox):

Event Acknowledges the idle alert?
Pointerdown on a tile, a tile-nav chord, a click on its tab Yes (human selection)
Typing into a tile Yes, via _ackDelivery on the input ACK
Opening the grid, restoring it, promoting a neighbor after a delete, auto-join of a new session No (auto: true)
A tile merely being visible No
Anything Never clears permission/question (action) alerts; those clear only on resolution

Notifications are unchanged: a visible but unfocused tile still raises its sound/title/desktop notification, so nothing is silently swallowed.

6. Seams (small refactors, no behavior change on their own)

Feature Today Change
Input socket _drainSession, _sendInputEphemeral, _redeliverSweep and _onWsInputAck assume the single _ws / _wsSessionId / _wsLastRecvAt An _inputSocketFor(sessionId) map that the main terminal and each tile register into ({ ws, ready(), lastRecvAt }). {t:'ia'} acks carry no session id, so each socket's onmessage passes its own: _onWsInputAck(seq, msg, sessionId). Without the map, tile input still works through the HTTP POST fallback (durable, but one awaited POST per record).
File-path links registerFilePathLinkProvider() reads self.terminal and opens with self.activeSessionId registerFilePathLinkProvider(terminal = this.terminal, getSessionId = () => this.activeSessionId) returning the provider; about five references change
Copy copyTerminalSelection / cleanedTerminalSelection read this.terminal; Pane B re-implements them Both take (terminal, sessionId); Pane B's copy is deleted
Image paste _handleImagePaste() uses the main terminal; _uploadAndInsertImages inserts with sendInput(), which re-reads activeSessionId AFTER the upload (an existing bug: switch tabs mid-upload and the paths land in the wrong session) _handleImagePaste({ terminal, sessionId }); insert with _sendInputAsync(sessionId, paths, { useMux: true })
Voice _insertText re-reads app.activeSessionId at insert time and appends to the main local-echo overlay Capture the target in start(); send with _sendInputAsync(target, …); skip the overlay when the target is not the main terminal
Shortcuts Ctrl+L (clearTerminal) and Ctrl+Shift+R (restoreTerminalSize) act on this.terminal Resolve through _focusedPane() returning { terminal, sessionId, isPrimary }; Close Session (no default key) already takes an id
Font, family, weight, skin setFontSize / setFontFamily / setFontWeight / applyTerminalSkin special-case this._splitPane Loop over all tiles

7. Fonts

Tiles get their own per-device font size, codeman-tile-font-size (default 13), because a tile is a fraction of the screen. While the grid is open, Ctrl +/- changes the tile font for all tiles. A font change is a geometry change (#464): every tile re-runs syncGeometry() afterwards.

8. Load cost

GET /api/sessions/:id/terminal runs three SYNCHRONOUS tmux calls (list-panes, capture-pane, display-message via execSync, each with a 5 s timeout). Nine full=1 loads at once would not run in parallel; they would run back to back on the event loop and stall every WS and SSE stream on the server for seconds.

So EVERY tile load goes through ONE grid-level client queue (PR 2, plugged in through a scheduleLoad option PR 2 adds to TerminalTile): the initial load, the refresh after a reconnect, a server {t:'r'} refresh, and the shell history pull. No tile calls fetch('/terminal…') on its own. The tile's single-flight flag stays (it is what keeps one tile's replays from interleaving); the queue sits in front of it and bounds the whole grid.

  • concurrency 1, focused tile first, then reading order (a user-triggered history pull jumps ahead of background refreshes);
  • after a deploy restart, the N reconnect refreshes drain one at a time instead of hitting the server together;
  • each load uses the BOUNDED window ?full=1&tail=TERMINAL_TAIL_SIZE (1 MiB) for TUI sessions and ?tail=… for shells, never an unbounded full=1;
  • a tile shows a quiet "loading" state until its turn;
  • full history is one action away: zoom then exit tiles, or exit tiles.

9. Coexistence with the split pane

Decided: the split pane stays for now. The two modes are mutually exclusive and share one tile class:

  • Opening the grid while a split is open closes the split first and seeds the grid with both of its sessions (Pane A's focused, Pane B's beside it), so "split, then want more" is one click.
  • While the grid is open, openSplitPicker and openSplitPane refuse and the Split button shows as disabled (aria-disabled), the same refusal pattern openSplitPane already uses for web tabs and the welcome screen.
  • Closing the grid never reopens a split.
  • The split's wrappers (selectSession, _onSessionDeleted) key on this._splitPane, which is null whenever the grid is open, so they stay inert there without changes.
  • Shared code lives in TerminalTile and the seams from PR 1, so a fix to tile behavior reaches both modes. Whether to retire the split later (a 2-tile grid covers it) is left for after the grid has been used for a while.

Edge cases

Situation Behavior
A tiled session is deleted (here or elsewhere) Tile removed; a neighbor gets focus with auto: true; the last tile gone falls through to the normal handoff
Closing the focused tile's session The grid stays open and the neighboring tile takes focus (grid-aware fallback in closeSession, see "Selections while the grid is open")
A tiled session is popped out to its own window Tile removed: that window now owns the PTY size
Session exited or not attached (pid === null, paneExit) The tile body shows "Not attached" with an Attach button: POST /interactive (or /shell for shell mode) with NO body, at most one in flight per session (the route has no in-flight guard of its own). A tripped PTY-exit breaker goes through the existing confirm before clearBreaker: true; no automatic path ever sends it
A web tab is opened Grid hidden by CSS; sockets stay up; hidden tiles send no resizes. Selecting a tiled session's tab brings the grid back
Window narrower than 1180 px Back to single view of the focused session; stored grid kept
Window shrinks below the tiles' minimum size The focused tile zooms with a short hint; widening restores the grid
Codeman restarts (deploy) Tiles reconnect; their refreshes drain through the load queue one at a time; handleInit reconciles ids without rebuilding live tiles
A half-open tile socket when a reconnect is kicked The old socket's handlers are detached before the replacement opens, so its late 4010 close is ignored
A phone opens a tiled session Its resize is declined while the desktop holds a sizing claim and was active in the last 90 s (existing Session.resize arbitration)
A second desktop browser shows a tiled session full-size Last resize wins and only the resizing socket hears zc (existing behavior, see follow-up 2)
Split collapses or a tile is removed mid-divider-drag Drag teardown first (carried over from the split's mid-drag fix)
Remote (SSH) and Docker sessions Work unchanged: their pane is a local tmux pane like any other
Multi-user mode The picker lists only visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side
Solo window Tiles unavailable

Server

No server change is required.

  • N sockets to N different sessions each use one slot of that session's MAX_WS_PER_SESSION = 5 (ws-routes.ts). There is no per-client or global WS cap.
  • One /api/events SSE stream already carries every session's lifecycle and hook events, so tiles need no extra stream for their status dots.
  • v:'desktop' resizes already register a sizing claim, so a phone cannot shrink a tiled session while the desktop is active.
  • The WS sends nothing on connect, which is why each tile loads its buffer first (already true for Pane B).

Separate follow-up PRs worth doing (see "Follow-ups").

Invariants this feature must keep

  • #464 geometry: a tile's xterm and its PTY never disagree; one function sizes both; zc adoption is columns only; withhold the fit wherever the resize is withheld.
  • Grid and split are never open together: opening the grid closes a split; the split refuses to open while the grid is open.
  • One place per session in this browser tab: the main terminal is parked while the grid is open, a session is in at most one tile, detached sessions are never tiled.
  • App-driven selections never collapse the grid: only a user-initiated pick (or an explicit leaveTiles) of a non-tiled session leaves it; every app-driven fallback (close, delete, restore) picks a tile.
  • One load queue: no tile fetches /terminal outside the grid's queue (initial, reconnect, {t:'r'}, history pull), because each capture blocks the server's event loop.
  • Only the current socket counts: a tile ignores events from any socket that is no longer this.ws, and detaches handlers before replacing one.
  • Tiles never touch main-terminal state: no long tasks counted against the main terminal's WebGL, and its snapshots for tiled ids are invalidated on exit.
  • Alerts: visible is not acknowledged; only a human selection or delivered input acknowledges idle; action alerts are never cleared by view or input; app-driven selections pass auto: true.
  • PTY-exit breaker: never clearBreaker from an automatic path.
  • Replay clears are in-stream (\x1bc queued in the write stream), never reset().
  • Capture fetches carry deadlines that cover the body (reuse CodemanFetchDeadline, as _pullHistory does).
  • Per-device setting: in displayKeys, stripped from the PUT, not in the .strict() schema; the --hidden marker class has a display: none rule.
  • Palette chords are swallowed in every xterm key handler.
  • Escape: the picker's close method returns early when the picker is not open (the global Escape handler calls every close method).
  • User text (names) via textContent / attributes, never innerHTML.
  • No secrets in localStorage: the stored grid holds ids only.
  • Memory: everything a tile creates is released in destroy().

Delivery: two PRs

Decided: the work ships as two PRs. PR 1 stands on its own: it builds the tile class and the seams, and the existing split pane runs on them, so users get a better split before the grid exists. PR 2 adds the grid on top.

PR 1: tile foundation (the split pane gets better)

Commits:

  1. Seams. The input-socket map; the parameterized link provider, copy, image paste and voice target; _focusedPane(); a _forEachTile() helper that replaces the this._splitPane special cases in the font, family, weight and skin setters. No behavior change on its own. Unit tests for each.
  2. TerminalTile. The class moves out of terminal-split.js into a new src/web/public/terminal-tile.js (load order 7.4, before terminal-split.js at 7.5) and is renamed from SplitTerminalPane. The split's orchestration (openSplitPane, closeSplitPane, the picker, the divider drag, the wrappers) stays in terminal-split.js and constructs a TerminalTile for Pane B. New in the class: reconnect with race-free socket replacement and the close-code table, the :tile cid suffix, reliable input through the socket map, zc handling with one geometry method, the file-path link provider and image paste, and an onExit callback. The scrollback and scheduleLoad options were deferred to PR 2, which introduces them together with the grid's load queue, their first user.
  3. Split pane follows focus. _focusedPane() returns Pane B while its xterm has focus, so Ctrl+L, Ctrl+Shift+R, voice and image paste act on the pane you are typing in. This removes most of the asymmetry the split-pane spec documented and accepted for its v1 ("Ctrl+L or Ctrl+W typed while Pane B has focus clears or closes Pane A"). Typing into Pane B now clears its idle alert through _ackDelivery, which it never did. Ctrl+W no longer closes anything (decision 5): Close Session has no default key, so Ctrl+W reaches the focused pane as delete-word.
  4. Docs. Update the split-pane paragraph in CLAUDE.md and docs/architecture-invariants.md#split-pane-sessions (Pane B now reconnects, delivers input exactly once, has links and image paste, and follows focus; the "deliberately plainer" list shrinks accordingly), add terminal-tile.js(7.4) to the frontend load order, add a note at the top of docs/split-pane-sessions-plan.md pointing here.

What users get from PR 1 alone: a split pane that survives deploys, never loses or doubles a keystroke across a reconnect, has clickable file paths and image paste, and whose shortcuts act on the pane that has focus.

PR 1 tests (gate):

  • test/terminal-tile-unit.test.ts, moved from split-pane-terminal-unit.test.ts with every existing case kept (single-flight, marker-last including the async-parse fake, history pull), plus: reconnect backoff and each close code, a late onclose (4010) from a replaced socket is ignored and the tile keeps running, zc columns-only adoption, the cid suffix, input routed through _sendInputAsync (as built: test/terminal-tile-input.test.ts, which runs connect() for real).
  • test/input-socket-map.test.ts: acks routed to the right session's queue, redelivery per socket, POST fallback when no socket is registered.
  • test/focused-pane-shortcuts.test.ts: with Pane B focused, Ctrl+L clears Pane B, Ctrl+Shift+R restores Pane B's size, voice and image paste target Pane B's session, and a user-bound Close Session still targets the active session; test/ctrl-w-never-closes.test.ts pins that no default shortcut answers Ctrl+W; with Pane A focused nothing changes.
  • Geometry: with the split divider at its 20% clamp, Pane B's xterm and the size it sends are both under 40 columns and equal (no floor regression).
  • onExit: each close code is reported once to the owner and stops reconnecting; the split shows the matching marker.
  • The image-paste wrong-session fix: an upload that finishes after a tab switch still inserts into the session it started in.
  • Every existing split-pane-* test keeps passing (class name updated where it is referenced).

PR 1 browser tests: the existing split-pane-*.browser.test.ts files (they match master, one pre-existing environmental failure in both). Pane B reconnecting after a server restart and a click on a printed path opening Pane B's file preview are covered by unit tests and checked live on the beta instance rather than as browser tests (the harness cannot restart its own server).

PR 1 verification: a split with two real Claude sessions on the beta instance; restart the server mid-typing in Pane B (reconnect, refresh, no lost or doubled input); type into Pane B while it has an idle alert (the alert clears); Ctrl+L in Pane B; image paste into Pane B.

PR 2: tile grid

Commits:

  1. Grid core. _refreshSessionPanels() extraction from selectSession (no behavior change, its own commit first), layout helpers, the grid section and CSS, parking with guards (SSE handlers, reconnect paths, WebGL long-task observer), the selectSession tile branch with the auto rule, the grid-aware closeSession fallback, focus rules, tile chords in the shortcut registry, the single grid-level load queue that every tile load goes through (a new scheduleLoad option on TerminalTile, plus a scrollback option for TILE_SCROLLBACK), coexistence with the split.
  2. Tile chrome and entry points. Header (dot, name, menu, zoom, add, remove), the Attach overlay, picker, dividers, drag-a-tab, Ctrl/Cmd+click, "Open group as tiles".
  3. Persistence. codeman:tile-grid restore inside handleInit (in place of the initial select), snapshot invalidation on exit, auto-join of new sessions from this tab, the showTileGridButton setting.
  4. Docs. A tile-grid paragraph in CLAUDE.md beside the split-pane one, tile-grid.js(7.6) in the load order, header-button and z-index notes, docs/architecture-invariants.md#tile-grid, a wiki page under docs/wiki/.

Testing (PR 2)

Gate (npm test):

  • test/tile-grid-layout.test.ts: computeTileLayout, tileGridCapacity, sanitizeTileGridState (unknown, deleted, detached, duplicate ids).
  • test/tile-grid-select-branch.test.ts and an extension of test/session-select-ack-gate.test.ts: the branch never calls _connectWs or _cleanupPreviousSession; acknowledgement only when user-initiated; an auto: true selection of a non-tiled session leaves the grid open; a user-initiated one closes it; leaveTiles: true closes it.
  • test/tile-grid-close-fallback.test.ts: closing (closeSession) the focused tile keeps the grid open and focuses the neighboring tile, even when the first sessionOrder entry is not tiled; closing the last tile falls back to the normal pick.
  • test/tile-grid-park-guards.test.ts: every guarded SSE handler is a no-op while tiles own the terminal, and the WebGL long-task observer counts nothing while tiles own the terminal.
  • test/tile-grid-load-queue.test.ts: N tiles reconnecting together produce at most one in-flight /terminal fetch at a time; {t:'r'} and history pulls go through the same queue; a destroyed tile's queued load is dropped.
  • test/tile-grid-restore.test.ts: with a stored open grid, handleInit restores the grid and never calls the main terminal's buffer load; on exit, snapshot and cache entries for every tiled id are invalidated.
  • test/tile-grid-split-coexistence.test.ts: opening the grid closes an open split and seeds the grid with both of its sessions; the split cannot open while the grid is open; the split's wrappers do nothing while the grid is open.
  • test/tile-grid-per-device-setting.test.ts and a hidden-button CSS case, in the shape of the split-pane ones: showTileGridButton is in displayKeys, stripped from the PUT, not in the schema, and .btn-tile-grid--hidden has a display: none rule.
  • Shortcut tests: the new chords are swallowed in the main and tile key handlers; mobile-header-buttons-policy keeps the button off phones.

Browser (npm run test:browser -- <file>, NOT in the gate):

  • Open 4 tiles; all render live output independently.
  • Click-to-focus routes keystrokes to the right PTY (assert with tmux capture-pane, never on HTTP 200).
  • Ctrl+L clears only the focused tile; Shift+Enter inserts a newline in a tile.
  • Zoom and unzoom refit; a divider drag sends exactly one resize per affected tile at pointer-up.
  • Deleting a tiled session removes its tile and moves focus.
  • Reload restores the grid; a server restart reconnects every tile.
  • Narrowing below 1180 px returns to the single view.

Reminder: npm test -- <browser file> matches nothing, runs zero tests and exits green. Use the browser runner for those files and read the file count.

Verification before merging PR 2

  • Build in the worktree and run an isolated beta instance (its own CODEMAN_INSTANCE, so its own data dir and tmux socket), reached through tailscale serve on a never-used port.

  • Six throwaway Claude sessions, as in the target picture; Playwright captures at deviceScaleFactor: 1, unique filenames.

  • A Chrome performance trace with all six tiles working at once for 60 s, recorded in the PR, with pass/fail bars:

    • p95 frame time at or below 25 ms (40 fps or better);
    • no long task of 200 ms or more after the initial load settles (three of those in 30 s is what trips the WebGL fallback, so the bar matches the codebase's own threshold);
    • JS heap within ±10% between minute 1 and minute 10 of a ten-minute run (no growth from tile churn: add and remove tiles and zoom a few times in between);
    • the initial load of six tiles completes with the server's event loop never blocked for more than one capture at a time (check Server-Timing on each /terminal response).

    Missing a bar blocks the merge or lowers the tile cap, not the bar.

  • A server restart while typing into a tile: reconnect, refresh, no lost or doubled input.

  • A phone opened on one tiled session: the desktop tile keeps its size while active.

Follow-ups (not in these PRs)

  1. Make captureActivePaneBuffer async (execFile) and add a server-side limit on concurrent captures, so no client can stall the event loop with captures.
  2. When a session's PTY size changes, send zc to EVERY socket on that session. Today only the resizing socket hears back, so a second desktop viewer keeps a stale width and renders garbled output (#464).
  3. WebSocket backpressure (bufferedAmount threshold, drop and send {t:'r'} on drain) for grids over slow links.
  4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer, a "Load full history" action inside a tile.
  5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
  6. Named grid presets, possibly per owner on the server.
  7. The end state: the main terminal becomes a 1x1 grid of TerminalTile, which removes the singleton from terminal-ui.js.

Decisions

  1. Clicking a tab that is not tiled. Decided: a user-initiated pick leaves the grid and shows that session in the single view; the grid is kept for one-click return. App-driven selections never leave it either way. Alternative: swap that session into the focused tile.
  2. The split pane. Decided: keep it alongside the grid for now; the two share TerminalTile and never open together.
  3. Persistence. Decided: per device, restored on reload.
  4. PR shape. Decided: two PRs. PR 1 is the tile foundation (the split improves on its own), PR 2 is the grid.
  5. Ctrl+W. Decided: it never closes a session. Close Session has no default key (Ctrl+W is delete-word in every shell and agent CLI, and it killed sessions with no confirm); it stays bindable in App Settings → Shortcuts.
  6. Ctrl+Shift+G with the Tiles setting off. Decided by the owner: the chord is inert while showTileGridButton is off (it passes through like any unbound key) and toggles the grid while it is on, so one setting governs both the button and the chord.
  7. The tile cap. Decided by the owner: at most 6 tiles for now. Six was tested and is smooth on the owner's desktop; nine missed the headless frame bar (p95 33 ms at 6 and 9 tiles under load, 16.8 ms at 4) and is untested on real hardware. The cap is one constant (TILE_GRID_MAX), the layout table keeps 7 to 9 working but unreachable, and the user-facing texts say "at most 6 tiles" when the cap, not the window, is what limits the grid.
  8. The Tiles button opens the grid directly. Decided by the owner ("when I hit the tiles button, open the tiles already!"): a click opens the grid with no picker in the way, choosing the grid this tab last had, else an open split's two sessions, else the open sessions in tab order up to the cap with the active one focused; Ctrl+Shift+G runs the same function. The picker is on right-click of the button (its title says so, as do the wiki and the Help modal).

Code anchors

Line numbers are approximate (as of 1.35.0) and drift; the names are stable.

Area Where
Split pane class and orchestration src/web/public/terminal-split.js (SplitTerminalPane, openSplitPane, closeSplitPane, _installSplitDividerDrag, the selectSession and _onSessionDeleted wrappers)
Split helpers src/web/public/constants.js ~1592-1634 (SPLIT_PANE_MIN_WIDTH, clampDividerPercent, buildSplitPickerSessions), exported as window.CodemanSplitPane
selectSession src/web/public/app.js ~7844-8644; early return ~7868-7876; deferred panels ~8452-8523
_cleanupPreviousSession app.js ~7377
Reliable input app.js ~3558-3920 (_sendInputAsync, _reliableSend, _nextSeq, _drainSession, _ackDelivery, _onWsInputAck, _redeliverSweep); main socket URL ~3341
SSE terminal fallback app.js _onSessionTerminal ~2192, _onSessionNeedsRefresh ~2916, _onSessionClearTerminal ~3020
Geometry terminal-ui.js syncTerminalGeometry ~5894, sendResize ~5981, _onPtyGeometryReport ~6072, throttledResize ~1292-1429; constants.js reconcilePtyGeometry ~1850
Link provider terminal-ui.js registerFilePathLinkProvider ~1831
Main key handler terminal-ui.js ~552-718
Shortcut registry app.js DEFAULT_SHORTCUTS ~406-557, SHORTCUT_ACTIONS ~1248, capture handler ~1263-1363
Status classifier app.js _sidebarRichRow ~5058; mobile-overview.js _mobileOverviewState ~130; dot CSS .home-sessions-dot--* in styles.css
Session action menu tab-rail-resize.js openTabRailActionMenu ~318
Tab drag app.js setupTabDragHandlers ~7137
Tab-group menu app.js openTabGroupMenu ~6737
Image paste image-input.js _handleImagePaste ~53, _uploadAndInsertImages ~130
Voice target voice-input.js start ~666, _insertText ~962
WS route and caps src/web/routes/ws-routes.ts (MAX_WS_PER_SESSION, frame handling), src/web/ws-connection-registry.ts
Resize arbitration src/session.ts resize / claimDesktopSizing ~4339-4426
Terminal capture src/web/routes/session-routes.ts GET /api/sessions/:id/terminal ~2986; src/tmux-manager.ts captureActivePaneBuffer ~3825
Attach session-routes.ts POST /api/sessions/:id/interactive ~1566