diff --git a/CLAUDE.md b/CLAUDE.md index 5d898d5d..8f2b1f01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index eac5367f..f86dd412 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -807,6 +807,8 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. +⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does). + ⚠️ **Chrome.** The header is a fixed 24px sibling of the body, refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 17496f1e..d05ec8ba 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -29,6 +29,14 @@ or settled a question the spec left open. The invariants as built are in 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 @@ -143,9 +151,10 @@ work also fixes gaps the split pane has today. ### Entry points -- **Header Tiles button** (its own button, beside Split). Opens a picker with - checkboxes over open sessions, ordered like the tab strip. When the grid is - open, the button toggles it closed. +- **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 @@ -879,6 +888,13 @@ exits green. Use the browser runner for those files and read the file count. 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 diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 4844c9aa..30fdf0a6 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -47,6 +47,7 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa | `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. | | `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. | | `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. | +| Right-click the Tiles button | Choose which sessions to show as tiles. | While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index 65fcf021..460e3da6 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -15,12 +15,14 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`. ## Opening a grid -- **Tiles button**: with the grid closed it opens a picker, a checkbox per open session in - tab order. It starts with the grid you last had (or the session you are on), says how many - tiles this window fits, and greys out the rest. **Open tiles** shows them. With the grid - open, the same button closes it. -- **`Ctrl+Shift+G`**: toggles the grid. Opening brings back the grid you last had, else an - open split as two tiles, else the session you are on. +- **Tiles button**: one click shows the tiles straight away. You get the grid you last + had; if there is none, an open split as two tiles; otherwise your open sessions in tab + order, up to six (fewer if the window is too small), with the session you are on + focused. With the grid open, the same button closes it. +- **Right-click the Tiles button** to choose which sessions: a checkbox per open session in + tab order, starting with the tiles you have (or had), greying out the rest once the grid + is full. **Open tiles** shows them, replacing what the grid showed. +- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does. - **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it, opening the grid if it was closed. On macOS use `Cmd`: `Ctrl`+click there opens the tab's rename instead. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index edab60ec..c60218bc 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1802,6 +1802,40 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds, exclude) { return result; } +/** + * What the Tiles button and Ctrl+Shift+G open, at once and without asking + * (owner decision 8). In order: + * a. the grid this tab last had (`stored`, already sanitized: live, not + * detached, at most the cap), if any of its sessions survive; + * b. else an open split's two sessions, Pane A focused; + * c. else the open sessions in tab order, the picker's list (no detached + * ones), up to `limit`, the active session always among them and focused + * (when it sits past the limit, the first `limit - 1` others come with it). + * Null when there is nothing to open. + * + * @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null, + * split?: string[]|null, sessions: Map, sessionOrder: string[], + * detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p + * @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null} + */ +function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) { + if (stored?.ids?.length) { + const focus = stored.zoomed || stored.focused; + return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] }; + } + const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id); + const pair = (split || []).filter(usable); + if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] }; + const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX)); + const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id); + if (all.length === 0) return null; + let ids = all.slice(0, max); + if (all.includes(activeId) && !ids.includes(activeId)) { + ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId]; + } + return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] }; +} + /** * Which tile takes focus when `id` leaves the grid: the next one in grid * order, else the previous one, else null. @@ -2116,6 +2150,7 @@ if (typeof window !== 'undefined') { tileNeighbor, tileInDirection, cycleTile, + tileGridOpenSet, TILE_GRID_MAX, TILE_LAYOUT_MAX, TILE_MIN_W, diff --git a/src/web/public/index.html b/src/web/public/index.html index 18da5d46..1dd89991 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -192,7 +192,7 @@ - +
—