**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.
`_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
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 |
| 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
| 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 |
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
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) |