The trailing unread dot sat 3.5px inboard on rows that also show a
pull-request icon, so the dots did not form one column down the sidebar.
`.session-row-state` centred its glyphs as one flex box, which only lands
the glyph on the action-icon axis when the box holds exactly one glyph.
Give each glyph its own fixed 14px grid track instead, so the trailing
glyph keeps that axis regardless of what precedes it.
* fix(ui): stabilize sidebar session list with a single projection owner
The sidebar flickered as gateway events arrived: rows popped out when runs
ended (active/pinned page bypass), rows jumped within createdAt ties after
paging (per-publish created-order pruning), child trees auto-collapsed when
a descendant went idle (per-render derivation), and running-row subtitles
blank-flashed between observer/narration updates.
SidebarSessionProjection now owns all presentation stability for the
sidebar lifetime: sticky page membership with user-action reset boundaries,
a monotonic bounded created-order registry, latched child expansion with an
explicit collapsed-by-user mode, and a last-subtitle hold per active run.
The scattered mechanisms it replaces are deleted (limitSidebarSessionRows,
partitionSidebarVisibleSections, per-publish order pruning, three expansion
key sets).
* fix(ui): hold running-row subtitles across run rotation with a display floor
Live A/B on a dev gateway showed two residual subtitle defects: the hold
was keyed to the exact active-run-id set, so the queued->running rotation
still blanked the row, and racing ambient sources (observer digest,
narration, work path) swapped the line A->B->A within a second.
The hold now lives for the session's running lifetime and ambient
replacements respect a 2s minimum display floor (matching the narration
throttle); attention, agent status, and the queued explanation bypass the
floor because the operator must act on them.
* test(ui): assert subtitle holds on non-localized sources
The run-rotation and critical-replacement tests asserted the localized
queued string through the shared i18n singleton; in the striped ui CI lane
(shared module graph, isolate=false) that resolution is environment-
sensitive and failed in checks-node-compact-large-11. Narration and
agentStatusNote exercise the same hold invariants with raw strings.
* fix(ui): reset sticky sidebar membership when grouping changes
ClawSweeper finding: grouping switches can re-emit the same section id
(e.g. ungrouped) with a different row population, so sticky keys must not
carry across; grouping now joins the membership reset boundary with a
regression row in the boundary table. The created-sort e2e migrates to the
sticky contract: an externally discovered newest session still lands on
top, and the previously visible page is retained instead of evicted.
* feat(ui): dock the session progress card beside a wide composer
The progress card had two placements: the companion rail when that side
panel is open, otherwise a collapsed one-line bar stacked inside the
composer box. On a wide chat the composer stays centered at the
transcript width, so the space either side of it sits empty while the
card is squeezed into the composer.
Add a third placement. When the measured free gutter beside the composer
is at least 280px, the card docks into it with its full checklist
expanded; below that it falls back to the existing composer bar, and an
open companion rail still wins. Exactly one placement renders at a time,
now expressed as a closed {card, placement} prop so the composer bar and
the dock cannot both draw the same card.
The gutter is measured from the DOM by a small ResizeObserver controller
rather than derived from the pane width: the transcript width is a
browser-local setting in arbitrary CSS units, and an open side panel
shrinks the conversation column without changing the pane. The dock is
positioned absolutely in that gutter, so the transcript and composer
never shift when a card appears or is dismissed, and its inline-start
edge repeats the composer's own half-width formula through a shared
--chat-composer-side-inset token so the two stay in agreement.
* fix(ui): keep the new-session composer sized outside the chat surface
The composer shell tokens are declared on .chat, but the new-session page
reuses .agent-chat__composer-shell outside it. There the var() had no
value, so the whole width declaration was invalid and dropped, and the
composer stretched full-width instead of holding its 48rem centered box.
Give both tokens their literal fallback at the use site, matching the
neighbouring --chat-thread-max-width. The custodian surface was already
immune because it overrides width outright.
Caught by ui/src/e2e/new-session-page.places.e2e.test.ts, which is the
regression test for this: it failed on the previous head and passes now.
Loaded older Control UI history now remains available after retained-pane eviction and remount, preserving pagination state and the user's visible transcript.
Refs #126978.
Reviewed-by: @shakkernerd
* fix(gateway): record workspace recovery failures
Persist bounded, deduplicated recovery-attempt failures through the session transcript while preserving pending workspace result fences, and prefer current recovery errors over stale terminal reasons.\n\nRefs #128850
* fix(gateway): revalidate recovery transcript ownership
Recheck the canonical session mapping inside the queued transcript transaction so a rebound session cannot receive a stale workspace recovery diagnostic.\n\nRefs #128850
* feat(ui): configure capabilities before session start
Reuse the active-chat Plus menu on new sessions, move Draft into it, and persist admin-scoped tool overrides before the initial turn. Closes#128079.
* test(ui): follow new-session Draft menu
* refactor(ui): reconcile new-session capability ownership
* fix(ui): gate terminal launch on capability overrides
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
---------
Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* test(ui): split settings node suite by concern
ui/src/app/settings.node.test.ts sat at 999 oxlint-counted lines against
the 1000-line max-lines cap, so any added assertion failed the core lint
shard — and, as in #128474, could pass locally while only the merge ref
tripped the cap.
Split the 42-test monolith along the surfaces it actually covers: the
base file keeps gateway URL derivation, tab-local token handling, and
per-gateway session scoping; preference persistence and layout
persistence move to dotted siblings, matching the existing
settings.sidebar-prefs.node.test.ts precedent. Test names and count are
unchanged (47 across the settings node suites, before and after).
The location/base-path/settings-fixture helpers and the storage
lifecycle hooks are now shared from ui/src/test-helpers/settings-node.ts
instead of being copy-pasted per file; sidebar-prefs drops its duplicate
copies too.
* test(node-host): write fake container-engine state atomically
checks-node-compact-large-3 failed twice on this branch in different
tests of node-worker-supervisor.container.test.ts, most recently with
"SyntaxError: Unexpected end of JSON input" raised from the fake engine's
load() while the supervisor ran `docker rm --force`.
The shim saved container state with a truncating fs.writeFileSync while
sibling shim invocations (rm, inspect, wait, ps) read the same path, so a
reader could observe the zero-length window and exit 1. Write to a
".pending" sibling and rename over the target instead; rename is atomic,
so readers always see a complete previous or next state. The ps handler
filters on the ".container.json" suffix, so pending files are ignored.
A concurrent write/read probe over the old pattern produced 2014 partial
reads in 212900; the same probe over write-then-rename produced 0 in
203468. Not reproducible end-to-end on macOS, where the truncation
window is far narrower than on the loaded Linux shard.
* improve(ui): put session row actions on the title line
Two-line sidebar rows centred the pin and menu buttons across the whole row, so
they floated between the title and the line below, and hover reserved their
width from the entire text column while fading the status endcap out. Hovering a
row therefore truncated the subtitle and hid its badges, spinner, unread dot and
relative time — the row rearranged itself under the pointer just to show two
controls.
The buttons now anchor to the title line, derived from the link's top padding
plus half the title's line box, and only the title yields their width. The
second line keeps its content and its icons. Single-line rows are untouched:
their one line already is the title line, so #126455's centring and the endcap
beside the title stay exactly as they are.
The reservation is deliberately not transitioned: startHoverMarquee measures
clientWidth synchronously on mouseenter, so an animated width would make long
titles scroll short and would silence titles that only overflow once the buttons
are up.
* fix(ui): keep second-line state lit while the actions are up
Hovering a two-line row to reach the pin or menu also faded its trailing state
out, so the run spinner disappeared under the pointer — the row stopped telling
you it was still working at the moment you reached for its controls.
That trade existed because the actions used to cover the endcap. On a two-line
row they now sit on the title line and cover nothing, so the second line keeps
its spinner, unread dot, badges and time. Single-line rows still trade: there
the endcap genuinely shares the actions' line.
* fix(ui): stop hiding row state and clipping it with the actions
Two problems the first pass left behind, both visible on hover:
The unread dot vanished the moment the pointer arrived on a single-line row.
That row reserves the action width in its text column, which already slides the
endcap clear, but the old fade still ran — so the dot disappeared while the
badges beside it stayed, reading as a glitch rather than a swap. Nothing trades
places with the actions now, on either row shape.
A 24px control centred on the 18px title line also hung 3px into the row below,
so a hovered button's fill would clip the top of the badges that now stay lit
there. Two-line rows shrink the control to the line it sits on; the 14px glyph
is unchanged and coarse pointers keep the full 24px target.
Measured on the preview: single-line endcap ends at 183 with the first action at
186, and the action box now ends exactly where the second line begins.
* fix(ui): even out endcap spacing and drop the touch swap leftovers
The unread dot sat further from the badges than they sat from each other: 6px
between the badges, 14.5px before the dot. The state box carried min-width and
min-height of 24px, sized to match the action button it used to trade places
with, so a 7px dot got 8.5px of padding a side. Nothing trades now, so the box
sizes to its glyphs and the endcap keeps one 6px rhythm.
The touch block kept the other half of that arrangement: state and actions
sharing a grid cell, and the relative-time trail permanently hidden. Both go the
same way — on touch the second line now keeps its content like everywhere else.
Covered by a case whose state holds a single dot, which is the only shape where
the padded box was visible; it reports [6,14.5] against the old CSS.
* fix(ui): draw every session row glyph at one size
The row drew seven different glyph sizes on the same two lines: archive 11px,
badges and spinner 12px, board 13px, fork and the action icons 14px, attention
15px, with stroke widths of 1.6, 1.7 and 1.8 mixed among them. Side by side on
one line that reads as broken alignment rather than a hierarchy.
They now resolve one --session-row-glyph-size and one --session-row-glyph-stroke
declared on .session-row-host, so the whole row moves together and a new glyph
inherits the rhythm instead of picking its own. Every rule keeps its previous
value as the var fallback, so the same components rendered outside a session row
are untouched.
The unread dot stays 7px: it is a dot rather than a glyph, and matching it to a
14px line icon would turn it into a blob.
A test walks a row carrying badges, fork provenance, a spinner and the action
icons and asserts one distinct size across them; against the old CSS it reports
["14x14","12x12"].
* fix(ui): size the session row run ring optically
The ring inks its whole box while the line icons beside it only ink 9-10px
of their 14px box, so unifying it to the glyph token made it read a size
larger than the pin and menu glyphs on the same line. Give the row host its
own ring token three px down from the glyph size; surfaces outside a row
keep the ring's previous 12px.
* fix(ui): put the whole session row trailing column on one grid
The action buttons stepped 25px inside 24px hover targets while the endcap's
badges stepped 20px and ran flush to the row edge, so a badge, dot or ring
never landed under the icon above it. Derive both gaps and the endcap's inset
from the action box, and give the state group a glyph-wide floor so bare
shapes centre on the same axis as icons drawn in a box.
* fix(ui): tighten the session row trailing column
Matching the endcap to a 24px action box spaced the badges 11px apart, which
read airier than before the grid landed. Take the action box to 20px so the
pitch drops to 21 and the badges return to a 6px-ish gap, derive the title's
hover reservation from that box instead of a fixed 52px, and let touch keep
the square button on two-line rows now that no hover fill can clip the badges.
* fix(ui): type the session row test fixtures and endcap lookups
check:test-types caught three errors the ui lane does not cover: the badge
fixtures pass hasAutomation and incognito, which the sessionRow helper never
declared, and the endcap gap probe indexed a possibly-empty NodeList.
* fix(ui): repair session row hover review findings
* fix(ui): preserve catalog sidebar interaction state
* fix(ui): preserve catalog interaction state during rerenders
* perf(ui): keep session rows within CSS budget
* test(ui): satisfy immutable array lint
* fix(ui): preserve catalog identity across session release
* fix(ui): remeasure adopted row live state