Owner answer: "paced connect: in". Measured on the checkpoint build: of
the 140 ms between the click on Tiles and its first frame, about 100 ms
was the six xterms being built inside the click, so nothing moved on
screen for that long and the entrance could not start.
openTileGrid now mounts every tile and lays the grid out as before, then
_connectTilesPaced builds one terminal per animation frame, the focused
tile's first, then reading order. The click paints its empty tiles in
about 20 ms and the entrance plays while the terminals are built. The
time until every tile has painted does not change: the load queue serves
one capture at a time, so only the focused tile's connect is on its path,
one frame later. Each tile still connects once, into its final cell (one
fit, one PTY resize).
- openTileGrid returns before the terminals exist, so a selection that
focuses a tile whose terminal is not built yet hands the keyboard over
in _connectTile (focusOnConnect), never when focus: false was asked,
and to the newly focused tile when focus moved meanwhile.
- _connectTile connects a tile once (entry.connected; a remount after
Attach resets it), so a re-form or a remount before a tile's turn is
never connected twice.
- A grid closed or opened again meanwhile stops the old run (a run
token), and asks for no further frames.
Tests: tile-grid-paced-connect.test.ts (the order, the final cells, the
keyboard, close and reopen, removed and remounted tiles); the tests that
read connect or the terminal's focus right after openTileGrid now run
the queued frames first (flushFrames in the vm harness), every
assertion kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: "when clicking on the tile button first make this
animation nicer". Recorded before: the click froze the page, six empty
tiles cut in at once, each tile's history then scrolled in visibly as
its load landed, and closing showed an empty single view for about a
quarter of a second before its own replay scrolled in.
The grid's own motion, on by default (not an entrance-animations.js
theme, which are off by default):
- Opening: each tile fades and settles in (opacity, translateY 6px,
scale .97), 180 ms, 24 ms apart in reading order: the last of six is
done at 300 ms. Its terminal stays transparent until the load queue
reports its first capture done, then fades in whole (160 ms), so no
replay scrolls by; a 15 s backstop shows it should that never come. A
tile added later enters the same way.
- Closing with the toggle (button, Ctrl+Shift+G; owner answer: only
these): the close stays synchronous, and a still copy of the tiles
(clones: no xterm, socket or listener; inert, aria-hidden, no pointer)
dims at once over the stage, holds until the single view's
selectSession has replayed its session (at most 700 ms), then fades
out. No empty single view between the two. A reopen drops a copy
still showing; a web tab hides it.
- A re-form to another count fades the old grid's copy out at once while
the new tiles enter.
- The count menu fades in (140 ms).
Every one animates opacity and transform only, so FitAddon measures the
final cell and each tile still sends one PTY resize (#464); nothing
moves, and no copy is made, under prefers-reduced-motion.
Tests (tile-grid-motion.test.ts): the stagger, the reveal and its
backstop, the same fits and connects with and without motion, the held
copy (released on settle, by its cap, removed on the last tile-leave or
its fallback), only the toggle animates, a reopen purges, the zoomed
tile alone, the re-form, reduced motion, and a CSS guard that every new
keyframe touches only opacity and transform. The vm harness gains
style.setProperty, cloneNode, isConnected and lastElementChild.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner decision 10 ("give me then the option to choose only HOW many
tiles, 2,4,6 default is 6 so the menu is easier"; asked where it lives:
"Click opens 6"). The click still opens the grid at once; right-click,
Shift+F10 or the Menu key on the button opens a small menu of three
counts, each drawn as the grid's own layout (2x1, 2x2, 3x2), the
remembered one checked. It replaces the session picker, which is gone
(method, markup hook, CSS, zh-CN entries).
- A pick is remembered per device in codeman:tile-count (default 6;
codeman:tile-grid stays ids only) and opens that many tiles. The click
and Ctrl+Shift+G then open with it, at most what the window fits.
- Which sessions: the rule the click already had (the grid this tab
last had, else an open split's two, else tab order, the active one
included and focused), trimmed from the end (the focused one kept) or
filled from tab order to the count. A remembered grid comes back with
its tiles first, in their cells, holes filled first, then tab order
(owner answer, superseding decision 8's "exactly the stored set"). A
page-load restore still brings back exactly what was stored.
- With the grid open a pick re-forms it: a count change is a shape
change under the cell model's rule (reformTileCells), the focused tile
always kept, every joining tile mounted and laid out before any of
them connects, so each fits once and sends one PTY resize.
- Ctrl/Cmd+click on a tab with the grid closed opens the count in total,
that session among them and focused (owner answer: N, not N+1).
- A count the window cannot fit is greyed out with the reason; a
remembered one stays checked, the keyboard starts on the largest that
fits. Arrows, Home/End, Enter or Space; Escape closes the menu alone
(the global handler gives it the key first, like the tab-group menu)
and puts the keyboard back on the Tiles button; Tab and a click
elsewhere close it.
- zh-CN for every new string (N 个窗格, 窗格数量, the titles, the Help
modal row); the i18n test harvests the menu now, with a session named
"6 tiles" as the user-text trap.
Tests: tile-grid-picker.test.ts becomes tile-grid-count-menu.test.ts
(the button checks kept, every picker check carried over to the menu,
plus keyboard, remembered count, re-form, Ctrl/Cmd+click and the batch
connect); the open-set, cap, restore, shortcuts, split-coexistence and
i18n expectations follow the count; the Help modal test escapes its
label (the new one has parentheses). The vm harness tracks
document.activeElement and makes SVG elements.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"Each session in the split view" (owner request) gets the tile header's
strip: the harness logo, the name and the model, painted by the same
_paintSessionHarness.
- Pane B's header is built from nodes now (it was innerHTML with the name
escaped) and follows renames and model changes on every tab render, like a
tile header; its close button is a tile button (26px target, 19px glyph).
- Pane A is the main terminal, which has no header of its own: while the
split is open it gets the same strip, minus the close, as the first child of
.terminal-wrap, and it names the active session. The strip takes 28px from
the main terminal, so the opening resize fits it with the strip already in
place, and closing removes the strip before giving the height back. Both go
through sendResize / syncTerminalGeometry (#464): the close no longer calls
a bare fitAddon.fit(), and a close that skips the server resize (Pane A's
session ended) still refits through syncTerminalGeometry.
- The partial-history banner, which overlays the top of .terminal-wrap,
starts below Pane A's strip while it is there.
test/split-pane-headers.test.ts drives the real split code on the grid's vm
harness: both headers, text-only names and models, refresh on a tab render,
no writes for an unchanged session, and the opening/closing fits.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tile header is now `● [logo] name · model ..... ⋯ ⤢ ×` (owner request):
- The logo is PR #532's `run-mode-dot <cliId>` slot, so the logos, the skins
and the plain dot of an id without a logo stay single-sourced in styles.css.
The id is data (a class and a catalog lookup), never a branch; the frontend
id-branching guard now also scans constants.js, terminal-split.js and
tile-grid.js (the one existing shell branch in tile-grid.js, the attach
route, is allowlisted with its reason).
- The model is the session's displayModel, as text in a data-i18n-skip span
inside a box whose tooltip may translate. Unknown means the logo alone.
- The logo's tooltip and accessible name say "<harness> · <model>", plus where
a model the CLI did not report came from ("set at launch", "custom
endpoint"; zh-CN patterns for both, the names pass through). The model's box
is aria-hidden so a screen reader hears the model once.
- One painter, _paintSessionHarness (terminal-split.js, shared with the split
panes next), diffs against what it last wrote, never the DOM: an unchanged
session writes nothing on a tab render.
- On a narrow header the model gives way first, then the name: the name does
not shrink at all and is capped at its box, since any shrink factor takes a
subpixel from a name that fits and ellipsizes it.
The chrome and zoom tests found header parts by child position; they now look
them up by class, with every assertion kept (the rename tests had been passing
against the new logo node by position). The i18n harvester files the logo's
labels as harness and model names that must stay as they are.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Five tile-grid tests defined the same `tileEl(id)` lookup and three more
inlined it; the harness (test/mocks/tile-grid-vm.ts) now exports it and
they import it. tile-grid-open-set built a second vm context just to read
constants.js, although the harness it already imports has loaded the same
file: it reads windowStub.CodemanTileGrid instead.
No assertion changed. Mutation-checked: tiles without their
data-session-id fail 34 tests across the seven files, and a broken
tileGridOpenSet fails the open-set cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A session Run makes while the grid is open joins as a tile right away, so
the tile connects and sends its size before Run starts the pane. The
server only records a resize for a session with no PTY and spawns the pane
at 120x40, and Run's own resize step measures the parked main terminal
(display: none, so nothing). Measured live for Shell and Claude: a 97x17
tile over a 120x40 pane, for good (#464).
The chrome refresh now remembers each tile's last-seen pid and calls
TerminalTile.paneStarted() when it appears or changes. paneStarted()
forgets the sent size and sends it; a hidden tile (a zoomed neighbour)
sends nothing and keeps it forgotten, so its next fit() sends it, which a
plain fit({ force: true }) would lose. Keyed on the sessions map, so a
handleInit after an SSE drop counts too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every Run path makes each session it created visible through
_ensureCreatedSessionVisible and then selects the first one, a human
selection that used to leave the grid for the single view. That helper now
hands the new session to _joinTileGridFromRun: with the grid open it joins the
next free slot, so Run's selection focuses its tile. No Attach overlay flashes
on it while Run starts its pane. 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, and a hint says the new
session opens on its own.
A tile's + adds "New session in this case": the normal Run (current run mode)
for the case the tile's session belongs to, with the toolbar's case put back
afterwards; the session it creates joins the grid like any Run from this tab.
Disabled for a session outside every case.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three more ways into the grid:
- Drag a session tab from the strip onto a tile: a session not yet tiled
replaces that tile in place (the replaced session keeps running); one
already tiled swaps places with it. A layout that is not full (3 tiles in a
2x2, 5 in a 3x2) shows its empty cells as slots, and a tab dropped on one
joins the grid there. Tiles and slots handle the drag in the capture phase
and stop it, because its payload is the session id as text and xterm's
helper textarea would type it into the PTY; a drag that is not a tab (a
file) is left alone. The dropped session takes focus (a human selection).
A sorted or grouped rail does not offer tab dragging, so neither does this.
- Ctrl/Cmd+click on a tab puts that session in the grid and focuses it,
opening the grid on what the Tiles toggle would bring back if it was
closed; on a window too narrow for the grid it stays an ordinary click.
- "Open group as tiles" in the grouped rail's group menu makes the group's
live sessions (as many as the window fits) the grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The grid now places every tile explicitly (grid-column / grid-row, reading
order) with a 6px divider track between columns and between rows, instead of
relying on DOM order and a gap. Track sizes are fractions (grid-template fr
values) that reset to equal whenever the column or row count changes.
Dragging a divider trades size between the two tracks either side, each kept
at the minimum tile size (the pure dragTrackFractions in constants.js, always
computed from the fractions the drag started with, so it cannot drift). The
affected tiles reflow locally at most once per animation frame, with no PTY
resize; each hears exactly one fit (one PTY resize) at pointer-up, and tiles
in other tracks hear nothing. Pointer capture keeps the drag on the divider,
the body locks the resize cursor and text selection for its duration, and
closing the grid or removing a tile mid-drag tears it down, as the split's
divider does. A zoomed grid shows no dividers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Tiles button beside Split in the header, opt-in through the per-device
showTileGridButton setting (read in applyHeaderVisibilitySettings; the
settings checkbox, displayKeys membership and schema exclusion follow with
persistence) and hard-gated like Split: hidden by its --hidden marker, a JS
width check with a live media listener, a @media (max-width: 1179px) backstop
and never in a solo window. While the grid is open the button closes it and
reads as pressed.
Closed, it opens a picker: a checkbox per open session in tab order (never one
popped out to its own window; one with no PTY is offered, its tile shows the
Attach overlay), names as text, preselected with the grid this tab last left,
else the active session and an open split's two. Boxes past what the window
can fit are disabled with the count shown, and Open opens the grid on the
checked sessions, focusing the active one if checked. Escape and an outside
click close it; its close method is idempotent and the global Escape handler
calls it.
A tile's + lists the open sessions not yet tiled; picking one adds it and
focuses it (a human selection). A grid that already holds what the window fits
disables the entries. "New session in this case" waits for auto-join.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tile has no live terminal when its session has no PTY attached (pid null,
e.g. restored after a server restart), when the agent exited in a live pane
(paneExit), or when the server closed the tile's socket because the session
exited (4009, which used to leave only the "session ended" marker). Its body
now says which, with an Attach button, in an overlay laid over the terminal
so the body and its xterm keep their size.
Attach is the single view's own re-attach: POST /interactive (or /shell for a
shell) with NO body, at most one in flight per session, since the route has
no in-flight guard of its own. A tripped PTY-exit breaker goes through the
same confirm before clearBreaker: true, and nothing automatic ever sends it.
On success the tile is remounted onto the new pane (a socket stopped for good
cannot reconnect), keeping the keyboard if it had it; the overlay stays away
while the server catches up, and a failed attach says so and keeps it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every tile gets a fixed-height header above its body: `● name ......... ⋯ ×`.
- The dot is the six-state classifier the tab rows and both home screens
share (_sidebarRichRow), with the existing .home-sessions-dot--* classes;
hovering the header says the state and for how long ("working 3m"). A
tile whose session waits on a permission prompt or question gets a pulsing
red border (box-shadow only, never layout; still under reduced motion).
- The name is text with data-i18n-skip; a double-click renames it through
the tab rename's own write queue (Enter or leaving the field commits,
Escape cancels, an IME composition owns Enter), and an in-flight name shows
as already applied, as on the tab.
- ⋯ is the tab rail's session menu (options, new window, close session with
its confirm); × removes the tile ONLY, the session keeps running. Neither
button focuses a tile that is not focused.
- The header is fixed at 24px so nothing in it can resize the body, and with
it the xterm and its PTY (#464).
Every tab render refreshes the headers, so they follow status and name
changes. Tabs of tiled sessions carry .in-tiles (both render paths), and the
tab strip re-renders when tiles come and go.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tile-grid-park-guards.test.ts carried its own copy of the fake DOM, written
before test/mocks/tile-grid-vm.ts existed, including the remove() that spliced
the wrong element when a child was no longer listed (fixed in the shared copy
only). It now uses the shared harness, which gains what the guards need: a
settable clock behind performance.now, the PerformanceObserver callbacks the
code under test registers, and localFit on the fake tile. Same 35 cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shortcut registry (DEFAULT_SHORTCUTS, group Tiles, all rebindable):
- Toggle Tile Grid, Ctrl+Shift+G: opens the grid this tab last left (one step
back after a selection outside it), else an open split as two tiles, else
the active session as one tile; pressed again, back to the single view of
the focused session. xterm emits nothing for a shifted Ctrl letter; the
browser's find-previous is overridden only where the grid can open.
- Focus Tile Left/Right/Up/Down, Alt+Shift+Arrows: a human selection of the
tile in that direction.
- Remove Focused Tile, unbound: the session keeps running.
tileShortcutFor() decides whether a chord applies (the toggle wherever a grid
could open, the rest only while one is open, so outside the grid
Alt+Shift+Arrows reach the terminal untouched) and is registry-aware. 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, so a chord that applies never reaches a PTY.
Coexistence with the split pane: opening the grid over an open split closes
it (no wasted resize for the pane about to park) and seeds the grid with both
of its sessions, Pane A focused. While the grid is open openSplitPicker and
openSplitPane refuse and the Split button reads as unavailable
(aria-disabled); closing the grid never reopens a split.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
selectSession gets the tile branch, right after its "already active" early
return: a tiled session is focused in its tile (_selectTiledSession: an
activeSessionId change, the shared _refreshSessionPanels and xterm.focus(),
no cleanup, replay, resize or socket of the parked main terminal). Decision 1:
a USER-initiated pick of a session that is not tiled leaves the grid for the
single view (the grid is remembered), and so does an explicit leaveTiles; an
app-driven pick (auto) never collapses it. A followed #session= link passes
leaveTiles (navigation).
App-driven paths pick a tile instead of the first sessionOrder entry:
- closeSession on the focused tile focuses the neighbouring tile (next in grid
order, else previous), captured before the await like wasActive, since the
delete broadcast may already have removed the tile; the last tile closes the
grid and falls back to the normal pick.
- A tiled session deleted elsewhere loses its tile and a neighbour takes focus
with auto (the last one lands on the welcome screen as before); a close from
this tab only drops the tile and leaves the follow-up to closeSession.
- A tiled session popped out to its own window leaves the grid.
Focus rules: pressing a tile is a human selection (pointerdown, never
preventDefault); Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles; only a
human selection acknowledges an idle alert. Home leaves the grid (remembered);
killing every session closes it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>