* feat(ui): add people session sorting
Offer People sorting in the Control UI session sidebar when the Gateway reports multiple session-sharing identities.
* fix(ui): restore sidebar translation dependency
* fix(ui): preserve session sort ordering contracts
* improve(ui): remove builtin observer dashboard injection
The Control UI fabricated a synthetic Observer tab + read-only builtin
widget on top of the real board snapshot whenever a session produced an
observer digest, which forced hasBoard=true and surfaced the prominent
chat/split/dashboard face switcher for sessions that never had a
dashboard. The dashboard face only duplicated the chat-rail observer HUD.
Delete the injection path, the builtin widget kind and its readOnly
concept, the pane-local ObserverDigestHistory, the observer widget
renderer/styles, and the orphaned chat.observer.* i18n keys. The session
header face switcher now appears only when the board provider has real
content. Chat-rail observer HUD, sidebar narration, config observer
settings, and protocol surfaces are unchanged.
* test(ui): give plugin widget cell chunk waits a real timeout
The four vi.waitFor calls on the lazily imported workboard-card element
used the 1s default, which a cold transform exceeds on loaded machines;
observed flaking 1-3 of 4 tests depending on host contention.
* feat(ui): turn the widget pin action into an icon button
The chat widget card's pin control was a bare text button ("Pin to
dashboard" / "Pinning…" / "Pinned"). It is now a 26px icon button using
the shared Lucide pin icon, styled like the adjacent widget-actions
trigger, with the accent color marking the pinned state. Labels move to
title/aria-label so tooltips and accessible names are unchanged, and the
two pin handlers collapse into one shared helper. The session-dashboard
e2e now records hover/pinned proof screenshots under
.artifacts/control-ui-e2e/workboard-pin when OPENCLAW_UI_E2E_RECORD=1.
* test(ui): assert pin button state via aria-label
The pin control no longer carries text content; the pinned state is
exposed through aria-label/title, which is what the widget-card unit
test now asserts.
* fix(ui): trim pin action label updates
* fix(ui): preserve commentary across steered refreshes
Retain keyed active-run commentary and its original timestamps so Control UI reloads keep progress on the correct side of persisted steering messages.
Closes#121448
* refactor(ui): simplify active-run replay coverage
Reuse the run-bound lookup and remove redundant transcript, hydration, and browser-test setup while preserving the steering refresh regression contract.
The persistent-identity footer for the viewer's own messages is
right-aligned, so the hover-revealed relative timestamp used to push the
always-visible sender name sideways. Flip the visual order with CSS
`order` so the time slots in to the left of the name and the name stays
pinned under the avatar. Peer footers are left-aligned and already
stable; DOM (screen-reader) order is unchanged.
* fix(ui): offer undo and a named recovery path when hiding a sidebar section
Hiding a session catalog section wrote the preference and returned nothing:
the section vanished, no undo was offered, and the only standing recovery
(Settings > Appearance > Sidebar) was never named anywhere near the action.
Announce the outcome at the producer. hideSessionCatalog now names the section
that disappeared, offers Undo, and carries the Settings path in the toast text
for after it times out. The hidden-catalog set gets one owner,
setStoredSessionCatalogHidden, so the sidebar, the undo, and the Settings
toggle all mutate the same authoritative read instead of each rebuilding the
set from its own copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): align the automation-sessions checkmark with its popover siblings
Every selection state in the sidebar sessions filter popover reads on the
trailing edge, but "Show automation sessions" used Web Awesome's
type="checkbox" checkmark, which its template renders before the label. The one
row in the menu that is a checkbox rather than a radio put its check on the
opposite side of the row from every neighbour.
Hide the leading part and render the shared session-menu__check in the details
slot, so the row matches GROUP BY / SORT BY / STATUS while Web Awesome keeps
owning the menuitemcheckbox role and aria-checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): stop toasts from ellipsing away their recovery instruction
app-toast__message was nowrap + ellipsis, so any message longer than one line
lost its tail. That is fine for a bare acknowledgement and wrong for a toast
that tells the operator how to undo or where to re-enable something: the part
that gets cut is exactly the part they need.
Wrap instead, clamped to three lines so a pathological string still cannot grow
the toast without bound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(ui): make the hidden-catalog writer module-private
With setStoredSessionCatalogHidden owning every hide/show, the raw
storeHiddenSessionCatalogIds writer no longer has a caller outside its own
module and the deadcode:exports gate flagged it. Drop the export rather than
keep a second public way to write the same preference, and move the two tests
that used it onto the owner they are actually meant to exercise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): anchor toasts to the bottom-right corner
Toasts were centered over the content column, which on chat routes puts a
passive notification directly on top of the composer. Move the shared
.app-toast container to the trailing bottom corner, the standard desktop
placement for passive notifications.
Anchoring it there alone would trade one overlap for a worse one: at a 20px
inset the toast lands exactly on the composer's mic and send controls. Chat
routes therefore lift it above the composer via --app-toast-inset-bottom on
.shell--chat, which the toast host inherits as a child of .shell. Routes
without a composer keep the tight 20px corner.
Phone widths keep the full-width bottom idiom, and now get it properly: the
centered toast used to collapse to its content width (200px measured at 390px
viewport), so the explicit left/right insets also widen it to 366px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): drop the chat-route toast lift
The corner toast no longer clears the composer on chat routes. Overlapping
the composer region is the accepted trade for one placement rule on every
route, so the route-specific 140px lift and its custom property are gone and
every surface uses the same 20px safe-area corner inset. Wrapped toast text
balances its lines now that the message can run to two.
* fix(ui): open the sidebar recovery path from its toast
Naming "Settings > Appearance > Sidebar" in the hide toast still left the
operator to find it. The recovery sentence is now a link built from the
settings-search destination that already describes that block, so the toast
opens the re-enable list instead of describing where it lives. Toast messages
accept a template for that.
* revert: leave the automation-checkmark repair to its own PR
#121429 fixes the same Web Awesome checkmark alignment on its own, with
browser regression coverage this branch does not carry. Two PRs owning one
CSS part is worse than losing the drive-by, so this keeps to the hide and
recovery invariant.
This reverts commit 6b1438a5be4bb1c9dd4c2ce9cd7d0dd54e77cb00.
* fix(ui): let the hide outcome reach a phone operator
On a phone the sidebar only exists inside the navigation drawer, and that
drawer is a modal dialog: a toast raised behind it is both occluded and inert,
so hiding a section there stayed exactly as silent as before this branch.
Promoting the toast into the top layer only fixes the paint half — a modal
dialog inerts everything outside it, so Undo and the recovery link still take
no clicks. So the hide hands the drawer back to the shell first, through a
seam the sidebar can use for any outcome that belongs on the main surface.
It is a no-op wherever the drawer is not open.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(ui): move chat view toggles into a header session menu
Control UI: replace the chat composer eye-icon View dropdown with a Claude Desktop-style three-dot session menu at the far right of the chat pane header. The menu adds Open in for editor/worktree sessions, Rename, the three display toggles under View, Fork, Archive/Restore with undo, and Delete with confirmation.
Reuse the sidebar session-organizer actions through a narrowed SessionActionHost seam, consolidate session permission gating in sessionMenuReasons, and remove the obsolete composer menu state, dismissal handling, tests, and CSS.
* test(ui): update header session menu e2e interactions
* fix(ui): center onboarding dashboard and quiet its chrome
The custodian onboarding route carried the settings takeover class, so the
shell reserved a hidden 288px sidebar column and the whole page rendered
off-center. Onboarding now opts out of the takeover, drops the duplicate
header identity (the thread already introduces the mascot), and option-card
hover no longer lifts or re-tints cards.
* fix(ui): stop next-step cards overlaying the message meta row
The option-card and wizard-step thread attachments pulled themselves up with
a -12px top margin, covering the preceding message's sender/timestamp line.
* test(ui): align nudge e2e with the minimal onboarding header
* fix(ui): soften default dark-mode body text to ~12:1 contrast
Drop --text/--chat-text from #d4d4d8 (12.87:1 on --bg) to #cdcdd1
(12.01:1 on --bg, 11.10:1 on --card) so dark body text sits inside
the 10-12 comfort band instead of above it; brighter body text
halates on dark backgrounds. Light mode, openknot, dash, --text-strong,
and --muted are unchanged.
* fix(ui): dim dark body text a further step to ~11.4:1
#cdcdd1 -> #c8c8cc (11.41:1 on --bg, 10.54:1 on --card), mid-band
instead of top-of-band.
* fix(ui): settle dark body text at ~10:1 on --bg
#c8c8cc -> #bcbcc0 (10.05:1 on --bg, 9.29:1 on --card) per visual
preview pick; low end of the dark comfort band on the page, slightly
under it on cards, still well above AAA.
* docs(ui): sync design-system guides with new dark --text token
ClawSweeper P3: color-tokens.md and accessibility.md still published
#d4d4d8 (~12.9:1); both now name #bcbcc0 (~10.1:1).
The collapsed session rail and the background-tasks toggle both rendered
icons.activity in the chat pane header cluster, so two unrelated actions
were indistinguishable without reading a tooltip. Background tasks keep
the activity metaphor; the rail now uses the companion sparkle, which is
already the product's assist glyph and does not collide with the
split-view panel icons at the end of the same row.
Closes#121418
* fix(ui): give the microphone picker one truthful state
Discovery now reports why it stopped as a code instead of prose, so the
composer popover can pick a single coherent rendering: with no selectable
input it shows one empty state rather than a checked System default row
stacked on two different ways of saying nothing was found. Rows drop
wa-dropdown-item type="checkbox", whose own leading check toggles on
click and disagrees with the trailing check bound to the stored device;
the chat pane gateway picker had the same mix and is fixed with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(ui): keep the microphone picker current while it is open
The empty state now promises the list keeps up, so both media surfaces
subscribe to navigator.mediaDevices devicechange for as long as they are
on screen and drop the listener when they close. The composer popover
re-runs permission-requesting discovery, since a microphone that just
appeared has hidden labels until the probe runs; the settings rows
refresh passively so hardware changes never turn into an unasked-for
browser dialog. Also drops the newly unused RealtimeTalkDeviceKind
export that failed the deadcode gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Rotating a device token showed the freshly issued secret through native
window.prompt from the shared Nodes operations module. A WebView without a
dialog bridge returns null for that call without rendering anything, so the
rotation completed on the Gateway and the only copy of the new credential was
discarded. Where the prompt did render it was an editable, unlabelled,
uncopyable text input that Escape threw away.
rotateDeviceToken now returns the token and DevicesPage, the visual owner,
presents it through a dedicated read-only reveal built on the canonical modal
dialog and copy button: selectable monospace value, copy control, show-once
copy, and dismissal gestures refused so a stray Escape or backdrop click cannot
destroy the secret. The shared operations module still presents no UI.
The epoch guard keeps owning the local-storage write and the list refresh, but
no longer swallows the response itself: the previous credential is already dead
on the Gateway, so a reconnect mid-rotate must not be what loses the
replacement.
The session-catalog project/group aggregator header rendered at the same
13px/500 as the session rows nested inside it, so a group read as just
another session. Bring the label into the sidebar's existing structural-label
family at one reduced step (smaller, heavier, lightly tracked) and keep the
case as authored, since these labels carry project, custom-group, and person
names. The head's color is deliberately left alone: #115646 raised it for
dark-theme readability, so type carries the whole separation.
Closes#121371
Menu rows stacked Web Awesome's shadow icon margin on top of our flex gap
(~17px gutter), let the icon slot baseline-align ~2px above the label centre,
drew shortcut hints at 550 weight beside 400-weight labels, and left the submenu
chevron 4px inside the hint column at a heavier size.
Rows now use a single icon column, optically centred icons, quiet mono keycap
hints on one rail, a chevron sharing that rail, and a divider inset to the same
columns. The control UI mock harness also advertises the session mutation
methods so the menu renders enabled instead of fully disabled.
Every dark palette defines --secondary with the same hex as --card, so the
markdown code chip painted with --secondary collapsed into the surface it sat
on (1.00:1 against a user bubble, 1.06-1.08:1 against the flat assistant
column) and --border was too close to the background to draw an edge. Light
mode never showed the bug because it overrode both properties.
Promote the light override into the single canonical rule: code chips and code
blocks paint --bg-muted with a --border-strong edge in every theme, and the
mode-specific overrides (including the raw rgba border) are deleted.
Also fixes the theme-contrast guard, which merged repeated `:root` blocks by
overwriting instead of merging, so the default `dark` theme resolved to an
empty token map and skipped every assertion. A new case reads the chip tokens
out of the shipped rule and asserts surface and border separation from --card
and --bg on all six themes.
* fix(ui): pin sidebar sessions optimistically in the mutation owner
Pin/unpin had no visible outcome until `sessions.patch` and the canonical
`sessions.list` refresh both landed: the row, its icon, and its section kept
the pre-click value for the whole round trip.
`createSessionMutations.patch` already owns an optimistic/rollback lifecycle
for model overrides, and the capability already exposes `patchRowLocal`, but
no pin path used it. Give the pin its own pending intent in the same owner:
flip the published row up front, chain `previous` through overlapping patches
so a rollback lands on the last Gateway-confirmed value, and let the newest
intent win when an older completion's list refresh republishes stale truth.
Retiring the connection or disposing the capability drops pending intents.
Every pin surface routes through this one operation, so the sidebar button,
the row menu, sidebar drag/drop, and the Sessions page all get the same
feedback and the same rollback. A rejected zone drop still writes no sidebar
slot: `handleSidebarZoneDrop` persists only on `completed`.
Closes#121247
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(ui): return only the pin harness fields the cases use
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): re-assert pending pin intents on canonical session rows
A sessions.changed event during a pin/unpin round trip queues a canonical
list replacement, and every Gateway session payload carries the server's
pre-patch pinned/pinnedAt. The optimistic row was therefore reverted to the
pre-click value until the patch resolved, restoring the no-feedback bug.
The session capability already has one seam where canonical rows become
published rows, so the pending pin joins swarm notes there instead of being
a write the next refresh can lose. Settling no longer republishes a newer
intent by hand; it only hands that intent the baseline the Gateway confirmed.
The optimistic write now moves pinned and pinnedAt together, matching the
Gateway (which derives pinned from pinnedAt) so an in-flight row cannot sort
into a slot the server would never produce.
* fix(ui): publish pin intents to every session list
The optimistic write only reached the primary session snapshot. The archived
and all sidebar filters keep their own published lists, so pinning from
either one still waited on the Gateway, and a canonical refresh landing
inside the patch window could leave them on a value the rollback never
corrected.
Both the intent and its rollback now republish through the roster's
decoration seam, so every held list lands on the same value in one pass and
a failed pin needs no extra canonical list to settle.
* fix(ui): take the pin rollback baseline from the published row
The rollback baseline came from the primary session snapshot alone. A
sidebar on archived or all renders its own subscribed list, so a row shown
only there recorded an unpinned baseline; a rejected unpin then rolled it
back to that guess and left a session the Gateway still has pinned looking
unpinned, with no refresh to correct it.
The roster owns every published list, so it answers where a row currently
is, and the mutation owner captures the baseline from that row.
* fix(ui): move a superseded pin's baseline to confirmation time
An overlapping intent's rollback baseline was rebased only after the older
patch's list refresh returned. A rejected unpin landing in that gap rolled
back to the pre-pin value, and if the refresh then failed the row stayed
unpinned while the Gateway held the pin.
The Gateway has committed once the patch resolves, so the baseline moves
there instead.
* docs(ui): record why pin intents need no rollback on connection retirement
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Control UI declares its typography tokens once, in ui/src/styles/base.css:
--mono and --font-body. Fifteen rules asked for --font-mono / --font-sans
instead, names that only exist in other documents (canvas widget host,
provider OAuth page, MCP Apps spec keys). Those references are invalid at
computed-value time, so the declarations were dropped: nine had no fallback
and silently inherited the body font, and the four `font:` shorthand sites
lost their size, weight, and line height along with the family.
Point every reference at the canonical token and drop the now-dead generic
fallbacks so ui/src has one spelling per font token.
ui/src/styles/base-theme-tokens.node.test.ts already guards this bug class
for color tokens (#113726, #113776) but never listed the font aliases; add
them so a re-introduction fails the existing lane instead of shipping.
Use a manifest-first inventory with independent coverage for manifest-only bundled capabilities.
Retire the undocumented thread-ownership plugin while Doctor removes stale references.
Document Talk voice and persist only provider-scoped voice selection.
Closes#121353
* feat(ui): float task-suggestion cards top-right with copy-prompt action
- Float a compact tray in .chat-main__conversation with absolute positioning, min(400px, 100% - margins) sizing, elevation, and internal scrolling.
- Remove the icon column, keep dismiss in the header row, and move a compact split button to the bottom-right.
- Add Copy prompt to the menu via shared copyToClipboard, copying the raw prompt while display-only sanitization remains documented inline.
Release note: Suggested-task cards now float as compact cards at the chat top-right and offer Copy prompt in the action menu.
* style(ui): neutral grey task-suggestion card and actions
Drop the accent-tinted card border/background, mute the eyebrow, and use default grey button treatment for the split control per operator direction.
* fix(ui): ungate copy-prompt and surface clipboard outcomes
- Menu now always renders with the client-local Copy prompt while acceptance-mode items stay capability+scope gated (addresses ClawSweeper finding 1).
- Copy success shows a transient Copied label and failures surface through the pane error path (finding 2).
- Task-suggestion pane logic extracted to chat-pane-task-suggestions.ts and the tray adapter moved into the component to keep files under the 700-line cap.
* fix(ui): test task suggestions through tray adapter
* test(ui): cover task-suggestion clipboard failure
* test(ui): split task-suggestion pane coverage
* chore(ui): raise startup JS budget for task-suggestion card feature
QA-profile measurement was 324617-324620 B versus the 324608 B effective budget; this owner-directed PR #121259 bump follows the baseline file’s documented feature-growth precedent.
* chore(ui): raise startup JS ceiling to 318 KiB for task-suggestion cards
The fixed 317 KiB ceiling (324608 B) was within ~20 B of CI-measured startup bytes after accumulated main drift; the task-suggestion card feature crossed it. Ceiling raised with in-file maintainer-approval comment per its own convention; ratchet baseline set to CI-measured 324626 B.
* fix(ui): keep task-suggestion copy available read-only
Remove the stale accept-or-dismiss tray guard now that Copy prompt is a client-local action. Keep Gateway-backed accept and dismiss controls independently gated, with a regression for operators who can only list suggestions.
* test(ui): cover list-only task-suggestion copy
Update the mocked-Gateway E2E to match the repaired read-only behavior: the suggestion remains visible, its mutating Start action is disabled, and Copy prompt stays available.
* fix(ui): scope the Control UI cursor convention to app-like display modes
The app-chrome cursor convention from #103357/#103411 was applied
unconditionally, so an ordinary browser tab lost the pointer hand on
buttons, menus, tabs, rails, selects and accordion summaries — the only
hover affordance a page owns. The convention is correct for the installed
window the manifest declares ("display": "standalone"), not for every
window the same bundle is served into.
base.css now owns one policy token, --cursor-action, selected by display
mode: pointer by default, the desktop arrow under standalone, minimal-ui
and window-controls-overlay. A low-specificity rule maps generic
actionable controls onto that token, restoring the affordance on the
surfaces #103411 stripped bare, while every component rule that owns a
semantic cursor (not-allowed, disabled, grab, resize, zoom-in, text,
wait) keeps winning without !important.
The 92 cursor: pointer declarations that had drifted back into ui/src
since July now consume the token instead of hardcoding the hand, so they
stop contradicting the policy in an installed window. Real hyperlinks
keep the pointer in every mode. The pre-boot mount fallback repeats the
policy locally because it must render when the bundle fails to load.
Closes#121242
* test(ui): run the cursor policy browser test in the node-driven project
ui/vitest.config.ts routes Playwright-from-Node .browser.test.ts files to the
unit-node project; without registering the new cursor policy test there, the
in-browser chromium project tried to import it and failed on node:fs/playwright.
* fix(ui): keep the desktop arrow in the native app hosts
The macOS dashboard embeds the Control UI in a plain web view, which
reports `display-mode: browser`, so the display-mode-only policy would
have handed it the browser-tab pointer. It already announces itself with
`openclaw-native-macos`/`-nav`/`-web-chrome` on `<html>`, the same markers
`ui/src/styles/layout.css` matches on, so the policy reads those too.
* fix(ui): confirm Devices pairing rejects and token revokes in-app
Reject device pairing, reject node pairing, and revoke device token asked for
confirmation through native window.confirm from the shared Nodes operations
module. Embedded webviews without a dialog bridge return false for that call,
so all three actions ended with no dialog, no request, and no recorded reason.
Move the confirmations to DevicesPage, the visual owner, onto the canonical
showConfirmDialog helper already used for inventory removal, and give the copy
real i18n keys. The shared operations module now presents no UI at all.
* test(ui): drive the revoke confirmation through the in-app dialog
device-token-reconnect drove the revoke prompt through Playwright's native
dialog event, which no longer fires now that the page owns the confirmation.
* fix(ui): confirm disruptive Control UI updates before dispatch
Every Control UI update affordance dispatched `update.run` (or the macOS
bridge `start-update` message) on the first click, so a misclick could
install new code, restart the Gateway, and interrupt active work.
Add one canonical confirmation gate in the Control UI owner boundary and
route the sidebar/settings-sidebar/floating update card and the
Settings -> Updates row through it. The gate owns the copy, the versions,
the restart consequence, the action-specific confirm label, and the
choice between the macOS bridge and `update.run`, so the bridge fork no
longer lives in the card and no surface can drift.
CLI, API, automation, agent-driven paths, automatic update campaigns, and
the `update.run` contract are unchanged.
Addresses the Control UI portion of #117178.
* style(ui): apply oxfmt to update confirmation sources
* perf(ui): load the update confirmation lazily and share its impact copy
Keeps the Control UI startup bundle under its 317 KiB gzip ceiling: the
confirmation dialog only loads when an operator opens it.
* test(ui): drive the update confirmation in sidebar and shell wiring tests
- Uses a four-column grid with inline top-row actions to keep the compact control pinned to the card corner.
- Fixes both split halves at 26px, eliminating the chevron overhang.
Release-note context: the suggested-task card's action button is now a compact split control in the top-right corner, matching the intended design.
* feat(gateway): task-suggestion acceptance modes with compact split-button card
- taskSuggestions.accept gains additive mode (worktree default | local | cloud | session) + cloudProfileId; operator.admin unchanged; worktree requests stay {taskId}-only for older gateways
- local reuses the plain-cwd create; cloud mirrors the Control UI create-empty -> sessions.dispatch -> send sequence with pre-claim profile validation and full rollback; session delivers the prompt into the source session (steer single active run, plain turn when idle, ambiguous refused) and can only cancel the chip on failure, never touch the source session
- Control UI card redesigned per owner reference: repo-basename eyebrow, clamped summary, instructions expander, split button with per-profile cloud items or a disabled "No cloud environment configured" hint
- TUI gains the same modes as selector rows with capability gating and double-Enter arming
Release-note context: operators now choose where an accepted follow-up runs — isolated worktree (default), the project checkout, a configured cloud worker, or as the next instruction in the current session — from a compact suggestion card.
* fix(gateway): negotiate task-suggestion acceptance modes and enforce steerable-run invariant
- Gate non-worktree task-suggestion actions on the negotiated Gateway capability.
- Require exactly one steerable run ID before delivering into an active source session.