* 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
Preserve restart recovery’s existing send identity through the Control UI notice projection so subsequent tool activity stays in one response frame.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* feat(geolocation): resolve client addresses to a coarse city via a bundled plugin
The Activity identity card could show a client's IP address but not where it
was, so an operator still had to look the address up by hand.
Add a bundled `geolocation` plugin that owns address-to-place resolution behind
one authenticated route, `GET /plugins/geolocation/lookup?ip=`. It downloads a
MaxMind-format database on first lookup into the state directory, answers from
that local copy, and refreshes it monthly, so a lookup never sends an address
to a third party. The Control UI renders the resolved city on the device row
next to the address and the client-reported time zone.
The default source is DB-IP City Lite under CC BY 4.0. That license requires
attribution, so every response carries the credit and the UI renders it next to
the value; the database is downloaded at runtime and never redistributed.
Plugin code and the `maxmind` reader are MIT. No free city-level IP database is
MIT-licensed, so the obligation lives with the data rather than the code, and
`databaseUrl` plus the attribution fields make the source swappable.
No new core provider kind: with one implementation the plugin owns everything
through the existing HTTP-route seam, keeping core plugin-agnostic. A second
provider is what would justify promoting this to a registry contract.
Availability and lookup failure stay distinguishable: a missing or still
downloading database answers 503, never `found: false`. A failed refresh serves
the cached copy, and a body that does not parse as an MMDB is discarded without
replacing a working database.
* fix(docs): correct geolocation config examples and add zh-CN glossary entries
The config examples used `plugins.<id>` instead of the real
`plugins.entries.<id>.config` shape, which the docs config-example
validator and src/config/docs-config-examples.test.ts both reject.
New doc labels also need zh-CN glossary entries.
* chore(labeler): cover the geolocation extension directory
AGENTS.md requires a labeler entry plus a GitHub label for every new
plugin surface; test/scripts/labeler-extension-coverage.test.ts enforces
the labeler half.
* fix(geolocation): address review findings on caching, download bounds, and scope
Cold-start lookups were permanently suppressed. The loader cached one promise
per address including failures, so the 15s browser deadline expiring against a
first download that takes ~46s cached a blank forever, and a mounted row only
looks up again when its IP changes. Lookups now return a discriminated
located/absent/unavailable result: only definitive answers are cached, and the
element retries an unavailable one on a widening 5s/15s/45s schedule.
Download limits ran after allocation. The size check happened only after
`response.arrayBuffer()` had buffered the whole body, and gunzip had no output
ceiling, so a replaced source or a compression bomb could exhaust Gateway memory
before rejection. The body now streams against a compressed ceiling enforced
per chunk, and inflation uses zlib's maxOutputLength.
Cached placements were not scoped to the Gateway. The cache keyed only by
address while endpoint and credentials come from the shared Gateway context, so
a switch could render the previous Gateway's answer. The shared reset hook now
supports multiple subscribers - a single slot silently dropped whichever
registered first - and the geolocation cache subscribes.
Unresolvable ranges no longer trigger a download. Only loopback suppresses `ip`
at connect, so Tailscale carrier-grade-NAT and LAN addresses are recorded and
displayed. No geolocation database contains them, so a tailnet-only or LAN-only
Gateway was downloading 125 MB to answer nothing. The route now answers those
ranges without loading the database, using the already-public
`isPrivateOrLoopbackHost` seam so the SDK surface budget is unchanged.
The quickstart queried a reserved documentation range while showing a located
response, which cannot happen; it now uses a routable address and documents the
not-found case.
* fix(deps): resync the lockfile after dropping the net-policy dependency
The geolocation plugin briefly depended on @openclaw/net-policy before
switching to the already-public isPrivateOrLoopbackHost SDK seam. The
package.json entry was removed without regenerating the lockfile, so the
frozen-lockfile install failed and every downstream CI job failed with it.
* perf(ui): consolidate Control UI boot chunk graph for HTTP/1.1 gateways
The Control UI boot flow (app shell + sidebar + chat route) lazily loaded
~124 automatic chunks in one burst after the gateway handshake, which the
gateway's HTTP/1.1 transport serializes into ~24 six-connection round-trips
on high-latency links (Tailscale, remote gateways).
Add a measured boot-module manifest (ui/config/control-ui-boot-modules.json,
regenerated via pnpm ui:boot-manifest:gen) and a control-ui-boot codeSplitting
group that merges exactly that module set into a handful of chunks with
recursive dependency inclusion. Lazy islands (locales, ghostty-web, novnc,
non-default routes) keep their own chunks; stale manifest entries degrade
gracefully back to automatic chunking.
Measured on the built dist with the mocked gateway (chat route, 3 runs):
unique boot JS requests 140 -> 45, raw boot JS 3751 -> 3717 KiB, chat
composer interactive at simulated 50 ms RTT ~1600 ms -> ~575 ms.
Largest-CSS budget rises 45 -> 47 KiB for the merged boot CSS; startup JS
gzip baseline ratchets down (345049 -> 339214 B) as consolidation shrinks
the startup graph.
* chore(ui): refresh boot module manifest after rebase onto current main
* fix(ui): stop the pending lazy shell action replay loop starving boot
When a pending lazy shell action (command palette open, panel toggle)
replayed while the shell was still splash-gated, the dispatched event had
no rendered element to consume it and re-entered requestLazyElement in a
microtask cycle: request -> load -> replay -> dispatch -> request. The
cycle starved tasks (Gateway WebSocket messages included), so the boot
never finished and the recovery e2e froze on the splash screen.
Gate replay on the element actually being rendered: the controller skips
the action after load until the host's render root contains the tag, and
restorePendingLazyAction skips dispatch while a defined element is still
render-gated. The host retries after every completed update, so the replay
fires on the update that first renders the element. Regression test fails
on the pre-fix controller.
* fix(ui): re-anchor the scope-upgrade details popover before opening
wa-popover resolves its `for` target once per property change and never
re-resolves a missing or replaced anchor. The trigger with the shared id
can render after the popover's first update (the header trigger ships with
the lazy chat chunk), leaving the opened popover permanently invisible:
active popup with a native [popover] part stuck at UA display:none because
showPopover() never ran without an anchor. Re-arm the watcher when opening
while the anchor is missing or disconnected.
* test(ui): compare settled layouts in device-scope stability assertions
The 0.5px no-move assertions sampled geometry that later reflowed when the
details surface's first render fetched glyph subsets, reporting sub-pixel
drift the open never caused. Burn in the one-time open per context and
sample the baseline adjacent to the click.
* fix(ui): map the keyboard shortcuts dialog in lazy replay gating
Current main added the keyboard-shortcuts lazy shell event; the replay
gate's exhaustive event-to-element record needs its entry.
* chore(ui): refresh startup budget baseline after rebase onto current main
Persist authoritative run ownership on native assistant and tool-result transcript rows so Control UI history can group each run consistently.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* feat(ui): show disk space in diagnostics overlay
* test(ui): cover unavailable disk diagnostics
* fix(ui): preserve status when disk lookup fails
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Restore the /new context-window selector from the selected catalog model and carry the choice into session creation.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* feat(ui): make sidebar visibility per-tab and free Cmd-click for new tabs
Sidebar visibility was persisted in localStorage, which is shared origin-wide,
so collapsing it in one tab leaked into every other tab and window on reload.
Visibility is view state, not a preference: stop persisting it and keep it in
the tab-local navigation snapshot. Width stays persisted.
A tab whose first load targets one specific conversation now starts collapsed,
seeded once at bootstrap from the canonical session-URL contract so bare /chat
and dashboards are unaffected and SPA navigation never re-collapses. Cmd-B,
the toggle, hover-peek, and the remote UiCommand path all still work.
Cmd/Ctrl-click on a sidebar session row now falls through to the browser so
sessions open in a real new tab or window; multi-select moves to Alt/Option-
click, which previously fell through to the browser's link-download behavior.
Shift-click range selection is unchanged.
* test(ui): prove per-tab sidebar visibility at the browser boundary
Covers the two states that matter: a bare /chat first load keeps the sidebar,
and a tab opened directly on one conversation renders that conversation
full-width with the sidebar collapsed and Cmd-B restoring it. Waits on the
composer, not just the missing sidebar, so the capture cannot pass against a
blank pane.
* fix(ui): narrow new-tab sidebar collapse intent
* fix(ui): collapse sidebar for catalog sessions opened in new tabs
* test(ui): split bootstrap navigation visibility coverage
* feat(control-ui): add Cmd+/ keyboard shortcuts overview dialog
A declarative shortcut catalog (keyboard-shortcut-contract/catalog) now owns
every Control UI chord: matching, platform-aware formatting, and the new
lazy-loaded Cmd+/ (Ctrl+/ elsewhere) overview dialog all derive from it, so
the overlay cannot drift from real handler behavior. Migrates the scattered
hardcoded shortcut labels (palette, debug overlay, appearance, sidebar,
terminal, workspace) and the duplicated Apple-platform check onto the catalog,
which also makes the previously meta-only sidebar and workspace chords
reachable on non-Apple platforms. The Slash matcher keeps Latin layouts that
put another printable on the physical Slash key (German Cmd+'-' zoom) intact.
* feat(control-ui): list non-obvious shortcuts in the Cmd+/ overview
Adds the invisible-but-real shortcuts to the catalog: composer history
recall (up/down), Esc dictation cancel, new-session-page submit (follows
the same chatSendShortcut preference as chat send), image-viewer zoom
(+/-/0), and the sidebar multi-select mouse chords (mod+Click,
Shift+Click) via a display-only Click key token. Multi-combo rows now
group their chips on the right instead of spreading across the row.
* fix(control-ui): keep Ctrl+Shift+Backquote toggling the terminal
The catalog matcher's exact-Shift comparison rejected the shipped
Ctrl+Shift+` terminal chord that main's predicate accepted (Shift was
deliberately unconstrained for layouts where the Backquote key is
shifted). Backquote now joins '/' as a Shift-insensitive key, with a
regression test. Found by ClawSweeper review on #128563.
talk.config forwarded only the operator ui.seamColor, so a user-selected
ui.prefs.accent never reached the native Talk overlay; it now wins with
the same precedence the Control UI applies (regression test fails
pre-fix). The widget-theme observer now explicitly watches the <html>
style attribute that carries the inline accent override instead of
relying on incidental data-theme re-sets.
Remove the bundled OpenProse plugin and /prose command now that upstream owns the maintained Agent Skill. Preserve /prose as migration documentation and let Doctor clean stale plugin configuration.
BREAKING CHANGE: The bundled OpenProse plugin and /prose command are removed.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(ui): stop offering Open in editor for remote checkouts
The session menu, chat header, file view and diff panel all offered
"Open in <editor>", which resolves to a cursor://file/<path> custom-scheme
URL. That URL is handled by the OS behind the browser, while the path comes
from the Gateway's filesystem, so the handoff only works when the browser
runs on the Gateway host. Viewed over Tailscale, LAN or a tunnel the click
did nothing at all: no error, no toast, no attribution.
Gate the four producers of the nullable absolute path on locality instead of
patching the render sites: a path is handed out only when the gateway
connection URL is loopback and the session is not pinned to a remote exec
node. Every surface already hides its editor affordance when that path is
missing, so the entry disappears rather than sitting there disabled.
Consolidates the two divergent loopback checks (gateway retry trust, board
sandbox origin) into ui/src/lib/gateway-locality.ts. Board sandbox-origin
detection widens from 127.0.0.1 to the full 127.0.0.0/8 range as a result.
* refactor(ui): move localEditorFilePath to the locality leaf module
session-diff-panel imported the helper from chat-sidebar-file-view, closing
a dependency cycle:
chat-sidebar-content-types -> chat-sidebar-file-view -> session-diff-panel
The helper is locality policy, not file-view rendering, so it belongs beside
isLoopbackGatewayUrl in ui/src/lib/gateway-locality.ts. Its content parameter
drops the FileSidebarContent type dependency for a plain { path, root } shape,
which makes the module a true leaf both callers can share.
* fix(ui): gate Open in editor on native-app locality, not loopback
A loopback gateway URL does not mean the checkout is on this machine. The
documented way to reach a remote Gateway is an SSH tunnel:
ssh -N -L 18789:127.0.0.1:18789 user@host
The browser then dials 127.0.0.1 while the workspace lives on the VPS, so the
previous loopback gate still offered a dead editor handoff on the single most
common remote topology (docs/install/hetzner.md, digitalocean.md, oracle.md,
raspberry-pi.md, platforms/linux.md, nodes/index.md, gateway/remote.md).
Server-side peer IP is fooled identically, and a sandboxed browser has no way
to tell the two cases apart.
The native macOS app does know: AppState.ConnectionMode is local/remote and
DashboardGatewayCatalog serialises it as `kind` on each entry of
window.__OPENCLAW_NATIVE_GATEWAYS__, so a tunneled gateway is "remote" despite
its loopback URL. Gate on that instead, via the existing nativeGatewaysCapability
seam, and drop the gatewayUrl plumbing the loopback attempt threaded through
four components.
Consequence: the editor handoff is now native-app-only. A plain browser has no
authoritative locality signal, so it never offers the entry — including at
genuine localhost on Linux and Windows.
Reported by ClawSweeper on #128420.
Improve mobile model and effort control spacing while preserving crowded-footer and desktop geometry.
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(nodes): automatic device placement for sessions.dispatch
sessions.dispatch gains autoDevice: true — the gateway selects the eligible
session-host node with the most available worker slots (deterministic
tie-break), retries up to three candidates when a node churns at the
pre-provisioning eligibility fence, and reports the chosen device in the
placement runner projection. Control UI offers Any available node with
actionable disabled reasons. No-eligible-host failures state why.
* fix(ui): break draft-place-state/draft-session-placement import cycle
resolveDraftSessionPlacement only needs four scalar fields; a structural
param type replaces the Pick<DraftPlaceState,...> import that created the
madge cycle.
* fix(ui): keep the devices section hidden when no devices are paired
The Any available node row lives inside the Your devices section; rendering
it with zero paired devices resurrected the section on gateway-only setups.
Gate it on device presence — Connect a machine remains the discoverability
path — and cover both the empty and non-hostable cases.
* fix(gateway): project dispatch runner state through the canonical reader
The dispatch reply no longer synthesizes an available device runner; it uses
the fenced workerPlacementRunnerAvailabilityReader (and disk-space reader)
exactly like session reads, so a node lost after durable provisioning
projects offline consistently. Documents placement.runner.deviceId in the
protocol reference.
* feat(ui): user-selectable accent color for the Control UI
Adds an Accent color section to Settings -> Appearance: curated preset
swatches plus a native custom color input. The choice persists as the
gateway-synced ui.prefs.accent (#rrggbb) and takes precedence over the
operator-level ui.seamColor, falling back to the active theme's accent
when cleared. The shared apply path now also derives readable
--accent-foreground/--primary-foreground ink from accent luminance, so
light accents keep dark text on primary controls.
Bundled accent-invariant cleanup: the embedded terminal resolves its
background/cursor/foreground from live computed tokens instead of a
hand-copied claw palette (fixes desync on knot/dash/custom themes),
a baked rgba(255,92,92) gradient now uses color-mix over --accent, and
duplicated diff-tint literals collapse into --danger-subtle/--ok-subtle.
Environment branding moved behind a lazy runtime module (-2.5KB gzip
startup JS).
* fix(ui): derive status glows and tints from semantic tokens
Status-dot glows, callout gradients, compaction-indicator borders, chip
borders, and dreams pulse animations baked literal green/blue/amber/red
rgba values -- several stale copies of old --danger/--info hexes -- so
they ignored theme families and light mode's deeper status hues. All 22
sites now color-mix over --ok/--warn/--warn-strong/--danger/--info.
* fix(ui): scope default accent swatch chip vars to a swatch class
Reusing .settings-theme-card--<theme> on the accent swatch made theme-card
Playwright locators resolve to two elements (strict-mode violation in the
prefs-reconnect e2e suite). The default swatch now uses
.settings-accent-theme--<theme>, added alongside the theme-card selectors
in the theme-invariant chip var blocks.
* fix(ui): carry --primary-hover through the accent override
The accent apply path set --primary but not --primary-hover, so dark
Claw/Knot primary buttons reverted to the theme hover color while an
accent was active (ClawSweeper P2). Derive it from --primary like the
accent hover, clear it on reset, and prove the resolved hover color via
a painted probe in the appearance e2e.
* feat(control-ui): show client IP and time zone on the activity identity card
The Activity identity card showed only host and platform, so an operator
looking at a teammate could not tell where that person was connecting from.
Presence already carried a best-effort `ip`, and it was simply not rendered.
Add the client's self-reported IANA time zone to the connect handshake and
presence entry, and render both `ip` and `timeZone` on the device row. The
time zone matters because the connecting address is frequently unusable for
location: connect handling omits `ip` for loopback clients, and tunneled or
Tailscale clients land in private/CGNAT ranges. A browser knows its own zone
regardless of how it reached the gateway.
Both protocol additions are optional fields, so no version bump is needed.
* build(protocol): regenerate Swift models for presence timeZone
* fix(ui): restore floating sidebar attention cluster on collapsed nav
* fix(ui): let stylesheets own sidebar-attention display and clear the collapsed header
The LightDomContents base stamps inline display:contents, which defeated the floating cluster's fixed flex layout; display is now stylesheet-owned. The collapsed chat pane header clears the cluster's resting width, and a stale-client refresh card drops below the strip. The layout browser test upgrades the real element so the inline-style regression is covered.
* feat(ui): link chat and presence identities to their activity feed
Extends the session hovercard's identity links to the surfaces where a
person is shown outside it: the chat pane header owner chip, the
participant and viewing-now facepiles, and peer message authors in a
shared thread.
The hovercard's helpers move to components/person-activity-link.ts,
which now owns how any identity surface links to /activity?person=<id>
and how a host supplies routing. Facepile links are opt-in so the
facepiles rendered inside sidebar row anchors and collapsed group
buttons stay plain: a nested anchor there would break the parent's
click target.
* fix(ui): keep the startup CSS under budget for identity links
The startup stylesheet sat 5 bytes under its 45 KiB ceiling, so the new
identity-link rules tipped `pnpm ui:build` over it. Compacts those rules
to one hover-scoped declaration and deletes the `.nav-collapse-toggle`
blocks orphaned when #100296 removed that button (only its `__icon`
child class is still rendered), leaving the sheet 93 bytes smaller than
before this branch.
* fix(ui): link peer authors in read-only task transcripts too
The read-only transcript wrapper rebuilds thread props field by field and
dropped the identity routing, so a peer author's name linked in the live
thread but stayed inert in a background task's history. Forwards it and
pins the parity with a test that fails when the line is removed.
Reported by ClawSweeper on #128369.
* feat(ui): link session hovercard identities to their activity feed
Names shown in the session hovercard are now links to that person's
Activity feed. `activityPersonLocation` in app-route-paths owns the
`person` query contract that the Activity filters parse and the sidebar
ONLINE list already hand-rolled, so every identity surface builds the
same link.
The creator avatar gets a decorative twin link (aria-hidden, out of the
tab order) and participant names keep the locale's own "with {name}"
phrasing and list separators while each name becomes its own link.
* test(ui): capture the hovercard identity at rest for PR proof
* fix(ui): align Inbox popover tabs with hub tabs and theme the mascot icon
The sidebar Inbox popover reinvented its tab strip: pill-highlight tabs
floating above an unrelated hairline, counts glued to labels as bare text,
zero counts always rendered, and a doubled focus ring (Web Awesome's stock
host outline plus the panel's own part ring). Replace the hand-rolled strip
with the shared renderHubTabs sub variant so the popover gets the system's
underline indicator, track hairline (doubling as the header separator),
pill count badges, and single focus ring; zero counts drop their badge.
The Ask OpenClaw lobster icon hardcoded a light-surface red gradient that
sank into dark backgrounds; its colors now flow through --lobster-icon-*
theme tokens (brighter stops in dark, original artwork in light).
Also removes the dead focusTab branch in selectTab.
* test(ui): match Inbox approvals tab accessible name to hub-tab content
The hub-tab strip names tabs by visible content (label + badge count), not
the removed per-tab aria-label.
* fix(ui): forward hub-tab aria-label to the shadow tablist
Web Awesome does not forward the host aria-label to its internal
[role=tablist], so every renderHubTabs surface (and the Inbox popover after
its hub-tab migration) lost the tablist's accessible name. Route the label
through syncTabGroupLabel inside the shared renderer and assert the shadow
tablist label.