Move the nightly cron from 03:17 to 03:23 UTC. GitHub sends scheduled-run
failure notices to whoever last modified the cron line, and after the merge
that is the contributor, so a maintainer commit has to touch it. The
docs below give no clock time, so they cannot drift from the cron.
Drop the "Keep the failure artifacts" step and the blank line before it.
No browser test writes test-results/ or screenshots-echo-diag/ (only the
ignore files name them), and if-no-files-found: ignore made the step upload
nothing without a word. The run log already carries the failure output.
Reword the workflow header. Drop the claim that the skipped suite let two
semantically conflicting PRs merge green: that incident came from
test/mobile/keyboard.test.ts, which this job does not run. Correct the
codex-predictive-echo note: the test uses a fake key in a throwaway
CODEX_HOME and skips itself when codex is missing, so it needs a codex
binary, not an authenticated one.
opencode-resize: record WebSocket resize frames under the socket's own URL
instead of appending '#' + the session id. The URL already carries
/ws/sessions/<id>/terminal, and the suffix let toContain(sessionId) pass for
a resize sent on any session's socket, the bug this test exists to catch.
Reduce the six session-id extractions (opencode-resize and perf-browser) to
data.data?.session?.id. POST /api/sessions always answers in the
{ success, data: { session } } envelope, and the dead fallbacks are what
hid the original breakage.
split-pane-terminal: restore the browser config's 60 s test timeout (the
added 20000 ms override tightened it), and replace the comment that blamed
Codeman's post-create clear. Under vitest the session is an echo PTY, so
that clear comes back as text; the real fix is useMux:false, since a plain
prompt otherwise goes through tmux send-keys, which test mode no-ops.
CLAUDE.md: the CI note now says the gate excludes the Playwright tests in
BROWSER_TEST_GLOBS instead of a stale count of 14, and names
browser-suite.yml; the Testing warning says the browser suite runs nightly.
CONTRIBUTING.md gets the same one-line pointer under Tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Move test/sse-tile-grid-filter.test.ts to an ephemeral port. The release
added it with #561 on fixed port 3287, after #556 was cut, so it is not on
the guard's LEGACY_FIXED_PORT_FILES and test/test-ports-guard.test.ts failed
on the merged tree. It now builds new WebServer(0, ...) and its url() helper
reads server.boundPort (only ever called inside tests, after beforeAll).
Converting it is preferred over listing it, since the legacy list is
shrink-only.
Update the five docs that still told contributors to pick a unique fixed
port, which the new guard now rejects for any WebServer test: CLAUDE.md
(Adding Features and Testing), AGENTS.md, .github/CONTRIBUTING.md and the
wiki's Contributing page (mirrored to the public GitHub wiki). They now say
to bind port 0 and read boundPort (or address().port for a raw server), and
note that the mobile suite keeps its fixed ports for now, because
test/mobile/helpers/server.ts caches servers by port, so createTestServer(0)
from two callers would share one server.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The owner's picks after testing the 1.36.0 beta:
- Header Stats Style defaults to Compact (two pills with rings) instead of
Tiles. The resolver, the pre-paint stamp and the App Settings option all
agree; an unknown value now reads as compact.
- The Tiles header button is ON by default everywhere but handhelds (their
defaults object keeps it off, and the button still needs a 1180px window).
The Ctrl+Shift+G gate in tileShortcutFor() now resolves an absent key
through the device defaults too, so the chord and the button cannot
disagree; before, it required a stored true.
- Tab Layout defaults to Classic (the single strip, as before). By state, By
case and Ledger stay available as opt-ins. The tile-grid beta the owner used
last had only this layout, and it is the one they wanted back.
zh-CN option labels follow the new "(default)" markers; docs and wiki updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On phones and 600-767px tablets the header strip stays one horizontally
scrolling row. #538 (Tab Layout, default "by state") orders that row in one
flex `order` band per state, so when the ACTIVE session changes state (a
prompt sent: idle to working; a permission prompt: needs you) its chip
moves to another band while scrollLeft stays put, and the tab in use left
the screen (measured at 390px: x 165 to -870). #257's reveal rules only
covered a CHANGED active tab: _updateActiveTabImmediate reveals on a
switch, and _fullRenderSessionTabs restores scrollLeft and re-reveals only
when _lastRenderedActiveTabId changed, while a state change is an
incremental pass that never reveals at all.
_noteActiveTabBand() records the active tab's band (read off the element,
so it is what is on screen) and reports when it moved while the tab stayed
active. Both render paths reveal on that, in the single scrolling row only
(_isScrollingTabRow: not wrapping, not a vertical list). It is keyed on the
band, not the raw order value, because another tab entering or leaving the
active tab's band shifts that value by one and another tab's move must not
yank a strip the user is browsing. _updateActiveTabImmediate records too,
so the pass right after a switch still counts (viewing a waiting tab spends
its alert and drops it into the idle row). The incremental path reveals
after updateTabOverflowMode(), and only from the branch that reached
_syncTabTriageChrome(), so a pass that falls through to a full rebuild
mid-loop leaves the record for the rebuild to compare.
Tests in test/tab-triage.test.ts pin the reveal on both paths and after a
switch, and that another tab's band change, a wrapping strip, a vertical
list and an active web tab never scroll.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spec's as-built list, the wiki's Tile Grid page and the CLAUDE.md
tile grid paragraph: the button has no native title, its hover card
says the count and what a click and a right-click do, it is the
button's aria-describedby (always present, hidden, kept current), and it
hides in the capture phase on any press, click or right-click so the
count menu never opens beside it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tunnel Upload URL page (upload.html) has been broken since the response envelope landed in 458fb81c: it reads j.filename and j.files while the server answers { success, data: { filename } } and { success, data: { files } }, so every upload reported "Saved: undefined" and the recent list stayed empty. Nothing else reads ~/.codeman/screenshots/, and handing a file to an agent goes through POST /api/sessions/:id/paste-image into the session's own workspace, so the page, its Settings row, the suffix branch of the tunnel row helper (now folded into its one caller) and the Upload URL i18n key go.
The three /api/screenshots routes keep working unchanged and log one deprecation warning per process on first use, naming paste-image as the replacement. Per docs/versioning-policy.md they are removed in a later MAJOR, after at least one MINOR release that carries the warning; the docs, CLAUDE.md and the multi-user plan say so.
Static caching: with upload.html gone no HTML is served by @fastify/static any more (every page has its own no-cache route; on a built tree only the precompressed index.html.gz artifact is reachable, as application/gzip, and nothing requests it). The .html branch of setHeaders was therefore dead and goes with the test that fetched upload.html to reach it; a comment now says a new static HTML page needs its own route. The index.html no-cache assertion on the route stays.
- docs/tile-grid-plan.md: owner decision 10 (right-click Tiles is a
2 / 4 / 6 count menu, default 6, remembered per device; the session
picker is gone; decision 8's "picker on right-click" and "exactly the
stored set" superseded) with the owner's answers on the details; three
as-built bullets (the count menu, the animation, painting first); the
Tiles-button bullet rewritten for the count; the entry points, the
capacity note, the multi-user row and the Escape invariant follow.
- docs/architecture-invariants.md: the Opening paragraph rewritten (the
count, the stored grid in its cells, the menu owning its Escape, the
paced connect and focusOnConnect, the motion rules); the z-index list
names the count menu and the closing grid's still copy.
- CLAUDE.md: the tile grid paragraph and the z-index line.
- Wiki: Tile Grid (the click, the count menu, the animation, reduced
motion) and Keyboard Shortcuts (right-click Tiles).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner feedback 1: "so the empty tab doesnt always have to be the last
one! so I can move freely around and the empty tab can also be tab nr
4 or 3". This replaces the slot refusal of aecada8c.
grid.cells (a session id or null per cell) is now the one source of
truth; grid.ids is a getter deriving the tiles in reading order, so
everything that only wants the tiled sessions (focus neighbour,
cycling, the load queue's order, the picker, closeSession) is
unchanged. The shape still comes from the tile count and the cap
counts tiles, never empty cells.
- A tile dragged onto an empty cell moves there and leaves its own
cell empty, nothing else moving (_moveTileToCell, through
_reorderTiles: no remount, reconnect or reload; only a tile whose
cell size changed fits). A tiled session's tab does the same; a tab
of a session not tiled yet joins in the cell it is dropped on. Each
slot knows its cell and reads "Drop a tab or a tile here".
- Move Tile goes to the adjacent cell: into it when empty, a swap when
a tile is there (tileCellInDirection).
- Removing a tile leaves its cell empty; adding one takes the first
empty cell. A shape change goes through fitTileCells: each tile keeps
its row and column when all fit (2x2 growing to 3x2), else the tiles
pack in reading order.
- Focus never lands on an empty cell: Alt+Shift+Arrows run over the
cells, Ctrl+Tab and Alt+[ ] over the tiles.
- codeman:tile-grid stays ids only: its ids are the cells with null for
an empty one. A reload brings the holes back when the shape is the
same (a session gone since leaves its cell empty), another shape
packs, the old packed format reads unchanged, and a followed
#session= link keeps the holes.
Docs: the spec's as-built bullet (rewritten in place), the wiki's Tile
Grid page and Keyboard Shortcuts, the invariants and CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The owner's answers on moving tiles:
- "dont move the tile": an empty slot no longer takes a tile. A slot is
always the last cell, so a move there shifted every tile after it.
Each drop target now says what it accepts (_acceptTabDrops'
`accepts`): a tile takes any session but its own, an empty slot only a
session not tiled yet. A refused drag is still held (dropEffect none,
no highlight), and dropSessionOnSlot refuses a tiled session too, its
tab included. A session not tiled yet still joins on a slot.
- A cancelled drag changes nothing, focus included (best practice): the
header focuses its tile on click, never on press, so a drag that ends
with Escape or outside leaves focus and the idle alert alone. The body
keeps press-to-focus, so focus still moves before a press reaches
xterm. The rename input stops its own clicks.
- A tiled tab dropped on the zoomed tile stays refused (confirmed).
- The Alt+Shift+Arrow focus chords skip a text field too (best
practice), as the move chords already did: shifted arrows select
there. Toggle and zoom are not text-editing keys and are unchanged.
Docs: the wiki, the spec's as-built bullet (with the owner's answers),
the invariants and CLAUDE.md say so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The wiki's Tile Grid page gets a "Moving tiles" section and the move
chords in its keys table (with what they leave to a text field and
take from a terminal editor inside a tile); Keyboard Shortcuts lists
the chords and the header drag. The spec records moving as an owner
request in its as-built list, with the reasoning behind the default
keys. The invariants and CLAUDE.md say every move goes through
_reorderTiles (no remount, reconnect or reload; only a tile whose cell
size changed fits) and that the header drag carries its own type and
is not draggedTabId.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- api-reference: the `config` source in the displayModel table, read again at
every pane start, attach and relaunch rather than restored.
- cli-registry: `modelDetect.configResolver` (a named, read-only, bounded
reader), the stock `deepseek-route` reader and its rules, and why the dsh
footer pattern needs a digit.
- deepseek-integration §4: Codeman reads the route for display only, the way
dsh-TUI resolves it; the catalog check it cannot see.
- architecture-invariants (tile grid), tile-grid-plan "as built" (owner
feedback 1), the wiki's model row, CLAUDE.md's source order.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- api-reference: the new `displayModel` session field, its sources in order
(custom-endpoint, statusline, screen, launch) and that it is untrusted
display text, persisted and restored when the CLI reported it.
- cli-registry: `capabilities.modelDetect` (one capture group, the last rows
of the probe's capture, anchored on chrome only that CLI draws), the two
stock patterns (dsh-TUI, codex) and the fifth config regex.
- architecture-invariants (tile grid): the header painter, the id as data, the
untrusted model text, no writes for an unchanged session, the truncation
order, Pane A's strip and its fits through syncTerminalGeometry.
- tile-grid-plan "as built", the wiki's Tile Grid page (logo and model rows,
Split's strips), and CLAUDE.md's tile grid and CLI registry paragraphs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spec records decision 9 and an as-built entry replacing the "+ / New
session in this case" one, and marks the target picture, the header line
and the + bullet as built without it. CLAUDE.md's header list, the
invariants' z-index line (the + menu's layer) and the wiki's Tile Grid
page (the header string, its table and the cap sentence) drop the +.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Drawing only the selected tab's family hid every other connection until you
clicked into one. All families are drawn again (still as routed trees); the
selected tab's families (what it spawned, and the family it was spawned into)
get .lineage-family--focus: 2.5px instead of 1.5px, full opacity instead of
0.75, a larger end dot, and drawn last so nothing covers them.
Lanes follow strip order (cycling through CodemanLineage.MAX_LANES, 3), never
the selection, so a family's lines never move when you click a tab.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: with App Settings language set to 简体中文, the grid reads
fully in Chinese. Every string the grid puts on screen gets its own
ZH_CN entry, so none reaches the generic leading-verb fallback: the Tiles
button (both states, with the right-click hint), the Tiles and Split
chips, the grid region, the Help modal's Tiles rows, the shortcut
registry's Tiles group and labels (overlay and App Settings list, and
"not bound"), "Open group as tiles", the picker, a tile's +, the header
buttons, the Attach overlay (not attached, attaching, exited, ended, the
hint), the empty slot, the dividers, the Split button while tiles are
open, and the toasts. Strings with a count, an exit code or a duration
are translateDynamic patterns: the cap texts (both wordings, with and
without ": the new session opens on its own"), "This window fits N
tile(s)", the auto-zoom hint, "The agent exited (N)" / "(signal N)", the
crash-restart confirm (the existing confirm wrapper runs it through t();
the session name passes through untranslated, in the single view too),
and the tile header tooltip ("idle 3m"), which requires the duration so
bare state words stay out of the table (they collide with other
surfaces, see mobile-overview.js).
Wording: 平铺 for the feature, 窗格 for one tile, 附加 for attach, 智能体,
案例, as the table already has them. Key names stay; Click, Right-click
(mouse actions) and Arrows in the Help modal's key column are translated.
English reads exactly as before (only additions to the table).
test/tile-grid-i18n.test.ts drives the real tile code through every
state that writes text, harvests each string and requires Chinese with
no Latin word left beyond key names and durations, and the same English
in en; plus the markup through the real translator in JSDOM, and session
and group names (also when they equal a UI word) staying untranslated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lineage lines used to draw one glowing dashed bezier per parent/child pair,
hanging below the tab strip, for every family at once. With a parent on row 3
of a wrapped strip and ten children below it, the curves crossed every lower
row's tab names and ran through the terminal text.
- One rounded orthogonal tree per spawning tab: every route starts at the
parent, so siblings share a trunk. 1.5px, solid, 1px dark outline; only a
working child's branch is dashed.
- Routes run only through the gaps between tab rows, joined by a spine left of
every row (computeLineageTree/computeLineageRows in constants.js), so they
never cross a tab or reach the terminal. The vertical rail gets the same tree
on its existing left track.
- Only the selected tab's family is drawn (what it spawned, plus its parent and
siblings). Selection redraws, and the strip's size transitionend redraws once
more, since the active tab widens for ~150ms after the selection redraw.
- The routing room is reserved by .session-tabs.lineage-tree, keyed on whether
any lineage exists, never on the selection, so a tab switch never resizes the
header or the PTY.
- Colours stay per spawning tab, now claimed in strip order for every family so
selection order never decides who gets the skin blue.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
Six was tested smooth on the owner's desktop; nine missed the headless
frame bar and is untested on real hardware. TILE_GRID_MAX (constants.js)
is now 6 and stays the one cap every limit reads; the layout table gets
its own bound, TILE_LAYOUT_MAX = 9, so the 7 to 9 layouts keep working
(unreachable) and going back to nine is that one line.
Every way in stops at the cap: opening, addTile, a tile's +, a session
Run makes, Ctrl/Cmd+click, the picker, "Open group as tiles", and a stored
grid with more ids (it comes back as its first six, focus kept only if it
survives, a dropped zoom cleared, row fractions that no longer match the
3x2 reset). The limits now go through one helper, _tileGridLimit(), whose
texts say which limit binds: "Up to 6 tiles" / "The grid holds at most 6
tiles" when it is the cap, "This window fits N" when it is the window.
Docs: decision 7 and an as-built entry in the spec, CLAUDE.md, the
invariants, the wiki's Tile Grid and Dashboard pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spec listed it as the default applied while the owner's answer was
pending. The owner has decided: with showTileGridButton off the chord is
inert and passes through like any unbound key; on, it toggles the grid.
Recorded as decision 6 in the spec (with a line under Gating), and as an
owner decision in CLAUDE.md and the invariants.
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>
- CLAUDE.md: a Tile grid paragraph beside the split-pane one (parking, the
one load queue, the selection and close rules, chords, dividers, auto-join,
the exited-agent case), tile-grid.js (7.6) in the load order, the
desktop-gated header markers and the Tiles picker in the z-index stack.
- docs/architecture-invariants.md#tile-grid: the mechanisms and the reason
behind each rule; the split section now says where a waiting grid load
differs and that every capture carries a deadline.
- docs/wiki/Tile-Grid.md: the user manual page (turning it on, the ways in, a
tile's header, keys, leaving, persistence, Split), linked from the sidebar,
The Dashboard, Keyboard Shortcuts and Settings Reference.
- The Help modal lists the tile chords (pinned in help-modal-shortcuts.test).
- docs/tile-grid-plan.md: status updated, and an "as built" list of where PR 2
went another way than the spec.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Close Session was bound to Ctrl+W by default. Ctrl+W is delete-word in
every shell, readline prompt and agent CLI, so muscle memory killed the
session (its tmux pane and CLI, with no confirm) mid-sentence, and with
the split pane open it was not even the pane being typed in.
Close Session now has no default key: the capture-phase handler lets
Ctrl+W through and xterm sends ^W to whichever pane is focused. The
action stays in the registry and can be bound in App Settings ->
Shortcuts; the shortcut overlay shows it as not bound. The Help modal,
CLAUDE.md, the split and tile-grid specs and three wiki pages stop
advertising Ctrl+W as kill. Owner decision (tile-grid decision 5).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLAUDE.md, architecture-invariants#split-pane-sessions and the split-pane
spec described Pane B as having no reconnect, seq-less input and xterm's
own Ctrl+V. Updated for TerminalTile (terminal-tile.js, load order 7.4):
reconnect and stop codes, the input-socket map and which input is kept
out of the persisted queue, image paste, the geometry rules, and
_focusedPane() with its Ctrl+W exception. The tile-grid spec now records
PR 1 as built (no key handler factory; scheduleLoad and scrollback move
to PR 2 with their first user).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Compact now draws every reading the same way, ring then label then
value: CPU and MEM get an accent ring (red past 80%, like their value)
in place of the sparklines, the plan windows keep their green, yellow
or red rings, the dot separators go, and both pills share one label and
one value style. The sparkline markup, history and CSS are removed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Idle is the default state of a tab, so naming it only adds noise. The
idle group is now quiet: its heading element stays (it anchors the row's
order band and, as the first row, holds its place beside the brand) but
draws no label or count. Needs you, Waiting and Working are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In the desktop header strip grouped by state, the brand leaves the flow
and sits over the strip's top-left corner, so every row after the first
starts at the left edge under "Codeman" instead of leaving that corner
empty. Labels are left-aligned in the measured column; the first row's
heading takes its natural width beside the brand (--tab-triage-brand,
kept by a ResizeObserver so no render pass reads layout for it).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tab Grouping becomes Tab Layout (tabArrangement: 'state' | 'case' |
'ledger' | 'classic', default 'state'), the first row of App Settings,
Appearance, Tabs, so the old and the new strip are one choice apart.
- By case (option A): each case's tabs sit in one .tab-cluster box in
first-appearance order, labelled with the case and its count, coloured
by a stable hash into the session palette. Membership is
_mobileOverviewCaseFor(), the home screens' own match. Inside a box with
company a generated w75-api-gateway reads w75; the -<case> stays in the
DOM in a .tab-name-case span only .tabs-clusters hides. The incremental
render path rebuilds only when the cluster structure key changes. The
rail and the sidebar get a labelled section per case; phones dissolve
the boxes into the chip row. Drag stays inside one box.
- Ledger (option B): CSS only on .tabs-ledger, desktop header strip: an
auto-fill column grid of equal cells in mono type with a 3px status
bar. Its markup is identical to classic's.
- State Order (tabStateOrder: 'urgent-first' | 'urgent-last'): flips the
by-state groups so needs you can be the bottom row.
- By state: the label column is measured to the widest label on screen
and the labels are right-aligned in it, instead of a fixed 92px gutter
that left short labels far from their tabs.
- Tiles: a three-row grid (label, value, bar) with pixel line-heights in
the bundled JetBrains Mono, 36px like the header. The bar used to lie
over a fixed 28px tile, and a taller system mono (SF Mono) pushed the
value into it. The WS tile's grid moved onto an inner .connection-tile
span because JS writes the indicator's display inline. Compact uses the
same font and a matched WS size.
Tests: test/tab-clusters.test.ts (new), plus the rename and the reversed
order in test/tab-triage.test.ts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two directions from Discussion #426, each a per-device setting and the
new default.
Tab Grouping (tabGrouping: 'state' | 'none', default 'state', option C):
tabs are grouped needs you (red, plus failed sessions), waiting (yellow),
working and idle (also ended, exited panes and web tabs), most urgent on
top. The desktop header strip draws a row per group with its label and
count in a left gutter; the flat vertical rail and the sidebar draw a
section per group; tablets keep their scrolling row with inline dividers;
phones keep the chip row in group order without headings. Classification
is the home screens' own (_mobileOverviewState/_mobileOverviewExit), the
fold into four groups is pure in CodemanTabTriage (constants.js). It is
flex `order` plus aria-hidden heading/break elements reconciled in place
after both render paths, never a DOM reorder, so Alt+N, the keyboard walk
and drag keep reading tab order; a drop is refused across groups. Named
groups in the vertical rail take precedence.
Header Stats Style (headerStatsStyle: 'classic' | 'compact' | 'tiles',
default 'tiles', option G): tiles give WS, CPU, MEM and each plan window
a label-over-value tile with a bar underneath; compact is one WS/CPU/MEM
pill with sparklines plus a plan-ring pill; classic is the header as
before. Desktop only (classic below 768px and in solo windows). The
clustered styles move #connectionIndicator into #headerSystemStats and
WS stays out of a hidden System Stats pill. The extra parts are always
rendered and hidden by default in CSS, so classic is unchanged.
Tests: test/tab-triage.test.ts, test/header-stats-style.test.ts; three
source pins in test/tab-rail-order.test.ts follow renamed lines.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- hooks-config: a probe the bulk cap refused gets ONE bounded re-probe past the
cap (probeBeforeTouching), and whatever is still unknown is skipped. The
per-spawn hook and statusLine helpers used to fall back to an unbounded
lstat/readFile there, which on a dead workspace never settled and could take
the last threadpool workers (and hang the boot hook sweep). New test: cap
engaged, stat/lstat/readFile hanging on two more paths; both helpers return.
- describeUnknownPath()/unknownPathReason(): POST /api/sessions, quick-start and
GET /api/cases/:name now say a folder was not checked (other mounts are still
not answering) instead of blaming a healthy folder at the stall ceiling.
errorCodes unchanged.
- #535 x #516: Create in a custom folder probes the parent through the bounded
probe before realpath/stat/lstat/readdir touch it; an unknown parent is 422
OPERATION_FAILED (UNREACHABLE) within the probe timeout. New test.
- Docs: MAX_STALLED default is 2 (follows UV_THREADPOOL_SIZE), CaseInfo
.unreachable covers a refused probe, the boot sweep skips an unanswering
workspace, a CLAUDE.md gotcha for bounded probes, verbs.md documents the 422
(plugin mirror synced), api-reference documents the custom-folder 422.
- Tests: the launcher case-lookup describe is no longer nested in the Grok
block, and the cap-below-ceiling test no longer depends on an inherited
UV_THREADPOOL_SIZE / CODEMAN_PATH_PROBE_MAX_STALLED.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- createEditCoordinator's finally block rebases the edits queued during a write; one that the write's 409 made inapplicable was dropped with no toast. It is now reported once, like the main loop and adoptExternal do (found by the PR bot's re-review; regression test fails without it).
- At the 32-group server cap the row and group menus no longer offer a new group, which could only fail with an untranslated 'group limit reached'. MAX_GROUPS is exported from tab-layout-browser.js.
- CLAUDE.md names the pagehide keepalive as the one deliberate exception to 'never PUT the layout outside the coordinator'.
- The Dashboard wiki page describes tab groups in the vertical rail row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A cached list of repositories below a folder is re-checked against the
Docker case workspaces as they are now, so a repository linked as a Docker
workspace within the 30 s list cache is no longer inspected.
- A diff past runGit's 8 MB output bound is cut short from git's partial
output instead of failing with a 500.
- The browser test waits for its slow route handler on unroute
(unrouteAll behavior 'wait'), so a late route.continue() cannot fail the run.
- "Upstream is gone" now reads "Upstream not on remote", true for a branch
that was never pushed as well as one deleted on the remote; docs mirrored.
- The diff route checks the repository against the workspace's own cached
repository list (findWorkspaceRepo) and refreshes only that repository,
instead of a fresh status of every repository in the folder.
- CLAUDE.md: a Key Patterns entry for the git read surface and its rules.
- The enclosing repository is identified with one cached rev-parse before
any full status, so an unrelated repository above the workspace costs one
process and its failure no longer hides the repositories below.
- Wiki: the bottom-bar indicator moves out of the header-controls table.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work
# Conflicts:
# config/test-suites.ts
# docs/api-reference.md
- never inspect a repository at or inside a Docker case workspace (walk-up, scan, diff route): git would run its clean filters on the host
- a branch whose upstream was deleted and pruned reports upstreamGone and falls back to commits on no remote, instead of green
- turning the setting off during a poll releases the in-flight flag
- log.showSignature=false; reword the docs: clean filters still run
- CLAUDE.md frontend load order, changeset names git-diff
- discovery reads a bounded, sorted directory listing; leading-dash paths allowed; diff 500 redacts credentials
- keyboard focus survives the poll re-render; panel stays on screen on narrow viewports; aria-expanded visible on light skins
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.
Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
reorderGroup, moveRef) applied to the rail at once, mirroring the server
model: a moved session takes the sessions that still follow it, and a
hand-moved child is marked placement 'manual'. normalizeLayout now keeps
placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
made in the same turn share a write; edits made while one is in flight go
out on the version it returns. A 409 replays the operations onto the
layout the server returned and retries (bounded); an operation that no
longer applies is dropped and reported. A 400 re-reads first; any other
failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
that changes nothing.
Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
Ungrouped and Move to new group in the vertical rail. Before the first
group exists it offers only "Move to new group", which is how a flat rail
becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
a hover glyph (a non-focusable aria-hidden span, so the treeitem still
holds no interactive child): Rename, New group, Move group up/down,
Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
back to its row or header), a pointer outside, Tab, focus leaving it, a
resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
the current editor releases the render guard. Enter or blur commits,
Escape cancels, IME composition keys are left to the IME, and the label
becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
row or into a group, a header drag reorders groups. Escape cancels; the
click that ends a drag neither selects nor toggles. The flat rail and the
header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
once it settles; a read otherwise rebases unsaved edits. On pagehide,
unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.
Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.
Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
Maintainer merge-time fixes for the three PRs that landed together on the
session create / launch / persistence path.
#514 findings (bot verdict merge-with-fixes):
- minor, fixed: SessionState.model was published and persisted for every
mode, so a codex/opencode cron session reported the app-wide Claude
default it never ran on. toState() now emits it only where the new
cliTakesSessionModel() holds (registry capability model.source ===
'claude-settings-file', no CLI id branch). POST /api/sessions uses the
same helper for its non-claude refusal, so refusal and publication cannot
drift. Recovery then hands back undefined for other modes on its own.
- nit, fixed: the `model` schema admitted a leading dash (and '.', '[').
The first character must now be a letter or digit; still a subset of the
registry's model-claude pattern, so nothing accepted is refused at launch.
- nit, fixed (reject, the consistent choice): `model` with
attachRemoteSession was silently dropped. Now a 400 INVALID_INPUT, as
#514 does for non-claude CLIs and quick-start does for remote cases.
advisorModel (#530) gets the same refusal there. effort and envOverrides
keep their older silent ignore on that branch so no existing caller breaks.
#515 finding (bot verdict merge, one nit):
- nit, fixed: the types/session.ts @fileoverview described CodexConfig as
(model, resumeSessionId); it now lists reasoningEffort, bypass,
animations and renderMode too.
Audit of the merged combination (not reviewed before):
- The conflict resolutions in session.ts (toState), types/session.ts,
reboot-restore-routes.ts, server.ts (restoreMuxSessions), CLAUDE.md and
skills/codeman/reference/endpoints.md (+ plugin mirror) keep both sides
correctly; nothing was lost or doubled.
- A claude session with both `model` and `advisorModel` launches with
`--model <id>` and ONE merged `--settings` JSON (ultracode + advisorModel,
or advisorModel beside `--effort <level>`), on the tmux template
(including the resume || new variant and with the statusLine exporter)
and on the direct-PTY fallback. Both values (and effort) survive
restoreMuxSessions onto a dead pane, a reboot restore into a fresh pane,
and restartCli/dead-pane respawn via _buildRespawnPaneOptions.
- quick-start and ralph-loop take no per-session `model` (matching #514's
scope, POST /api/sessions only) and launch on the app-wide default, which
toState now persists for claude, so recovery stays consistent.
- No defect found in the combination beyond the findings above. Noted, not
changed: advisorModel is still published for any mode a caller sends it
with (launch-inert there; the UI and skill send it for claude only).
Tests: test/session-model-recovery.test.ts pins the pair through both
recovery shapes for effort ultracode/high/none, the recovery constructors'
fields, the tmux-manager builder hop, and the codex/opencode/shell
non-publication; test/advisor-model.test.ts pins the launch lines and a
real direct-PTY Session's pty.spawn argv; the route test covers flag-shaped
models, attach refusals and the published fields. Docs: SessionState.model
docstring, the reboot-restore-registry header, the golden test comment and
the CLAUDE.md model/advisor bullets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Maintainer merge-time fixes for the grouped vertical rail (#517) and its
tree semantics (#519), from the two PR reviews.
#517 minors
- A collapsed group hid rows that need the user with no signal on its
header. The header now takes the most urgent alert among the session
rows its collapse hides, in the tab alert language (tab-alert-action
red ring, tab-alert-idle yellow ring, the existing ::before rules
extended to the header). New pure hiddenGroupAlerts() over a per-section
`hidden` list; _syncTabGroupHeaderAlerts() patches it on BOTH render
paths, since alerts change without a rebuild. The kept selection draws
its own ring and is not counted.
- Every layout read rebuilt the whole tab strip, and failed reads retried
every 5 s forever. _applyTabLayout() now rebuilds only when the
structure key changed. The key drops the layout version (bumped on
every session create/close and order PUT) and instead carries group
names and the rows each collapse hides, so a version bump that moves
nothing costs nothing and a rename still rebuilds. The load coordinator
backs off (5, 10, 20, 40 s, capped at 60 s) and stops after 4 retries;
the next SSE init or tab:layoutChanged tries again, a success resets.
- A malformed stored collapse value disabled collapse on that device for
good. A parse or shape error now reads as nothing collapsed and is
rewritten to []; ok:false stays reserved for a store that throws.
- Ctrl+Shift+{ / } still reordered across groups, where the server
re-ranks per group, sends no session:orderChanged and leaves this
client's sessionOrder and Alt+N targets diverged. The move is now a
no-op unless the neighbour is in the active session's own section
(_canSwapActiveTabWith, reading the projection's new sectionByRef, which
also covers rows a collapse hides). Within a group the swap still works
and the server agrees with it; the flat rail and the strip are
unchanged.
#517 nits
- Keyboard group toggle dropping focus: already fixed by #519's
focus-by-identity; the Enter toggle test now pins focus on the header.
- Header <button> inside role=tablist: moot, #519 made the header a
treeitem inside role=tree.
- Byte-identity test not comparing against master: skipped in the suite
(a test cannot read another revision's files portably). Checked by
hand instead: the flat strip and flat rail markup of this branch before
and after this commit are identical in all 16 cases (both orientations,
manual and activity sort, no layout and zero groups, full and
incremental paths).
- Doubled blank line in docs/architecture-invariants.md: removed.
#519 minors
- A tap on a tree header or unselected row dismissed the touch keyboard:
the roving tabindex parks those at -1, so the [tabindex] arm of
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR missed them. The selector now
lists [role="treeitem"].
- The tree key handler acted on keys pressed on a focused control inside
a row (Enter on the overflow button re-selected and reloaded the active
session instead of reopening its menu). It now returns unless the key
landed on the treeitem itself.
#519 nits
- aria-posinset/setsize went stale when the activity-sorted grouped rail
re-sorted rows on the incremental path. The position pass is extracted
(_applyTabTreePositions) and re-run, with aria-selected and the header
alerts, at the end of the incremental branch while the rail is a tree.
- An expanded group with no open rows was announced as an expanded parent
owning an empty group. A group with no open rows is now a tree leaf: no
aria-expanded, no aria-owns, its rows container presentation; Left and
Right do nothing on it, and its chevron keys off the section's
collapsed class instead of aria-expanded.
Tests: tab-layout-browser (malformed storage, backoff with a bounded
drain, structure key, hidden alerts, leaf groups, sectionByRef),
tab-layout-rail (header alerts on both paths, render-on-change, backoff
without rebuilds, malformed storage, Ctrl+Shift section gate, in-row
control keys, leaf header keys, posinset after an incremental re-sort,
the dismiss selector matching tree items), and three new Chromium tests
in tab-activation.browser (Enter on a focused overflow button, the touch
keyboard staying up on tree taps, the collapsed header's red ring). Every
new test fails on the pre-fix sources. Docs: architecture-invariants
owner-tab-layouts and keyboard-dismissal sections, one clause in
CLAUDE.md's dismissal rule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Maintainer merge-time fixes for the MCP server sync (opt-in mcpSyncEnabled, synced, default OFF).
M1, parse errors echoed config text (secrets included) into the HTTP response and Settings:
smol-toml's TomlError carries a code frame of the offending lines and V8's JSON "Unexpected
token" errors quote source. Both catch sites now go through describeMcpSyncError(): a parse
failure is reported by line/column only ("not valid TOML (line 3, column 21)", "not valid
JSON"), an errno failure by Node's own message (code, syscall, path), the module's own
messages via a McpConfigError class, anything else as "unexpected error". Tests put a secret
on the broken line (TOML, both JSON message shapes, and a write refused at the re-parse that
would have quoted a copied server's env) and assert it is absent from the result and from the
route's response body; they fail against the old code.
M2, CODEX_HOME / CLAUDE_CONFIG_DIR / XDG_CONFIG_HOME were ignored, so a sync could create a
file the CLI never reads and report success: new optional registry field
capabilities.mcpConfig.relocation { envVar, path } (registry data, no id branch; schema
reuses the env-name and no-traversal path rules). Declared for claude (CLAUDE_CONFIG_DIR,
checked in the 2.1.289 binary), codex (CODEX_HOME), opencode (XDG_CONFIG_HOME) and gemini
(GEMINI_CLI_HOME, gemini-cli paths.ts); antigravity follows $HOME only (agy 1.1.12 has no
relocation var). Resolved from the server process env at call time: absolute moves the file,
empty means unset, anything else reports the target with the new status "skipped" plus the
reason and writes nothing. Dedupe is now by resolved file. When a caller overrides `home`
without passing `env`, process.env is not consulted, and the route tests clear those vars so
a CI runner's XDG_CONFIG_HOME can never aim a write outside the temp HOME.
M3, feature undocumented: CLAUDE.md Key Patterns paragraph (opt-in, admin-only, additive
only, backups, re-parse validation, 0600 for copied secrets, names-only responses with
position-only parse errors, capabilities.mcpConfig and relocation), a Settings-Reference row
in the wiki, and docs/cli-registry.md + docs/api-reference.md updated for relocation, the
"skipped" status and the error policy.
Nits:
- N1 Preview/Sync before Save: the UI remembers the saved value on open and says "Save
settings to turn MCP sync on first" instead of calling the routes; the 403 message also
says to turn it on and save.
- N2 non-admins in multi-user mode: _applyMcpSyncAdminGate() hides the whole MCP group, called
from applyMcpSyncVisibility() and the codeman:me event like the CLI-management gate.
- N3 scope chip says "synced".
- N4 "(1 servers)" pluralised; the unsupported list only names installed CLIs (route test
pins it with a per-test installed set).
- N5 McpSyncResult / McpSyncTargetResult moved to src/types/mcp-sync.ts (barrel export); only
the route imported them, so no churn.
Verified with an isolated instance (throwaway HOME, own instance and tmux socket) and
Playwright: chip, save-first message, preview rendering and the admin gate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge-time fixes for the webhook notification channel (ntfy, Slack, Discord, generic JSON).
Minor 1, App Settings Save silently dropped webhook edits: the modal's main Save now
persists the webhook group beside the settings PUT, the same way it already saves the
model config (saveModelConfigFromSettings), but only when the group differs from what
loadWebhook() put on screen (_webhookPending), so an untouched group never re-PUTs. A
refusal (bad URL, enabled with no URL) shows a warning toast, keeps the modal open and
scrolls to the group with the pasted URL still in the box, instead of a success toast.
Send test now saves pending edits first, so it never tests the old URL while the box
shows a new one. The row says so in one line.
Minor 2, no test for the server.ts glue: new test/webhook-push-glue.test.ts drives the
private sendPushNotifications on a real (never started) WebServer with an EMPTY push
store and webhook.json in the instance data dir, delivering through the real
egress-guarded fetch to a local receiver: a permission prompt arrives with the
host-prefixed ntfy Title and body while Web Push is never called, an immediate repeat is
deduped, "response complete" is skipped under scope attention and sent under all, and a
disabled config or a non-push event sends nothing. Verified it fails when the webhook
call is moved below the "no subscriptions" return.
Minor 3, docs: webhook.json added to CLAUDE.md State Files; a Webhooks section in
docs/wiki/Notifications-And-Approvals.md (setup, what is sent, the secret URL, public
ntfy topics, local targets allowed, dedupe, instance-wide reach in multi-user mode) plus
a table row, and a line in Settings-Reference; new section 10c in
docs/security-architecture.md for the second outbound channel through the web-tab
egress guard.
Nits:
- Orphaned JSDoc: the webhook schema moved below the push schemas, so
PushSubscribeSchema has its comment back.
- Duplicated enums: WebhookUpdateSchema uses z.enum(WEBHOOK_KINDS/WEBHOOK_SCOPES), so
the schema cannot accept a kind the store would coerce away.
- describeError classifies egress refusals with isEgressBlockedError (the
CODEMAN_EGRESS_BLOCKED code anywhere in the cause chain) instead of a message regex;
tests pin a deep cause chain and that matching words alone are not a refusal.
- Markup: the URL input uses set-input, the whitespace-only line is gone, and the switch
row hints to pick a long random topic on public ntfy.sh.
- Remove a saved URL: a "Remove URL" button (shown only while a URL is saved, with a
confirm) sends { url: "", enabled: false }.
- Types placement: WEBHOOK_KINDS/SCOPES and WebhookKind/Scope/Urgency/Config/Result/Status
moved to src/types/push.ts (the IO-side WebhookMessage/Request/Fetch stay in the module).
Browser test extended: main Save persists a pending edit, a refused URL keeps the modal
open with the URL, Send test saves a newly pasted URL first, Remove URL clears it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude Code's advisor tool (code.claude.com/docs/en/advisor) lets the session's
main model consult a second, stronger model at decision points: before
committing to an approach, on a recurring error, and before declaring a task
done. Codeman can now start claude sessions with one.
- `advisorModel` field on POST /api/sessions, /api/quick-start and
/api/ralph-loop/start (fable, opus, sonnet or a full model id in those
families; haiku cannot advise and is refused). Stored on the session and
persisted, so respawn, boot restore and reboot restore keep it. Remote and
docker quick-starts refuse it, as they refuse effort.
- App Settings, Models, "Advisor" segment (Default / Sonnet / Opus / Fable),
synced as `claudeAdvisorModel`. Run, resume and the Ralph wizard send it.
Default sends nothing, leaving the CLI's own /advisor choice in charge.
- Carried as the `advisorModel` key in the launch's single --settings JSON,
merged with ultracode and the statusLine exporter, never the --advisor
flag: `claude --advisor haiku` exits 1 at launch, which would leave a dead
pane on every respawn, while the settings key degrades to no advisor. A
launch without an advisor is byte-identical to before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The grouped vertical rail is now an ARIA tree with a tree keyboard model,
and tab rows are pinned as full-row activation targets whose controls keep
their own actions and stable hit targets.
Tree semantics (grouped vertical rail only):
- #sessionTabs becomes role=tree while grouped and returns to its shipped
role=tablist and label when grouping ends. The header strip, sidebar and
flat rail keep role=tablist / role=tab exactly as before (the flat rail's
markup is unchanged byte for byte).
- A named group's header is a level-1 treeitem with aria-expanded that
aria-owns its rows' role=group (rows are level 2). Ungrouped rows and the
row a collapsed group keeps showing are level-1 items; a collapsed header
owns nothing, and the "Ungrouped" heading is a visual divider hidden from
assistive tech. aria-level, aria-setsize and aria-posinset are set on every
item, and aria-selected follows the selection without a rebuild.
- Exactly one treeitem carries tabindex=0 (roving). Controls inside rows
leave the tab order, so Shift+F10 / ContextMenu open a row's actions
(session action menu, web tab settings).
- Up/Down walk visible items, Home/End jump, Right expands a header or enters
it, Left collapses a header or climbs from a row to its header, Enter/Space
select a row or toggle a header. With the activity sort on, the walk follows
painted order within each group; the flat list keeps its whole-list walk.
- Focus survives a full re-render by identity (a row a collapse just hid hands
focus to its header), but a render never pulls focus into the rail.
- The group header is the treeitem itself (no nested button), still toggled by
click through the same onclick and still the lineage proxy anchor.
Full-row activation:
- Clicking a row's status dot, mode chip, name or padding already selected it
upstream; that is now pinned in real Chromium for the strip, the flat rail
and the grouped rail, together with every control (gear, detach, close,
overflow, web tab gear and close) running only its own action.
- The close control now shows a pointer like its siblings instead of the
default arrow.
- Enter/Space on a focused web tab in the flat list opens it; it used to call
selectSession(undefined).
- The action controls are pinned to stay under the pointer when a row is
hovered (no reflow-on-hover moving the gear out from under a click).
New Chromium suite test/tab-activation.browser.test.ts is listed in
BROWSER_TEST_GLOBS (run with npm run test:browser).