* fix(ui): restore visible gateway offline status and announce restarts in the sidebar footer
The sidebar footer has been the canonical offline indicator since #112600
(which deleted the top connection banner in its favor), but #125070's footer
compaction made it sr-only + aria-hidden - invisible to everyone. Restore the
visible red offline pill (click-to-retry, queued count, redacted error
tooltip) and add a restart-aware amber state: the gateway already broadcasts
shutdown { restartExpectedMs } before restarting and refuses drain-phase work
with reason "gateway-restarting", so the Control UI now shows "Restarting..."
immediately - before the socket drops - and degrades to the offline pill when
a restart overruns max(3x restartExpectedMs, 15s).
One shared renderSidebarConnectionStatus serves the main and settings
sidebars; the dead sr-only spans and clipped live region are deleted. The
drain reason strings become shared gateway-protocol constants. The e2e mock
now passes Vite's vite-hmr client socket through to the native WebSocket so
going offline in source-served suites no longer triggers a dev-client page
reload mid-test.
* refactor(ui): delete dead sr-only footer subtitle spans and fit the startup budget
The connected-state identity subtitle spans (gateway health dot, name,
build subtitle) were the same sr-only + aria-hidden dead-markup class the
previous commit fixed for offline: invisible to sighted users and screen
readers, with the identity button's aria-label already carrying the same
information. Delete them plus their orphaned CSS; tests now assert the
aria-label contract.
The e2e mock's Vite dev-client socket passthrough keys on the vite-hmr
subprotocol. The gateway store drops validation the shared timer clamp
already owns and re-arms the restart deadline on every fresh drain
rejection (server still says restarting, so amber stays honest). The
startup-budget baseline refresh (340901 -> 341446, jitter-safe max of
three builds) records the ~530 B this feature legitimately adds over the
512 B per-change tolerance; reason stored in the baseline file.
* fix(ui): keep ordinary gateway stops on the offline pill path
ClawSweeper finding: every shutdown broadcast armed the amber Restarting
state, so a deliberate stop hid the Offline/Retry action for the 15s
deadline floor. The amber state now arms only when the broadcast carries a
numeric restartExpectedMs; ordinary stops flow through the normal offline
pill. The server omits restartExpectedMs on non-restart shutdowns instead
of broadcasting null, aligning the wire with ShutdownEventSchema's
optional integer.
Also rewires build-info-unicode.e2e to the identity aria-label (the
visible subtitle span it asserted was deleted as dead markup) and splits
the restart-state store tests into gateway-store.restart.test.ts with a
shared test-support harness to respect the max-lines cap.
* chore(ui): tighten startup gzip baseline after composer-queue rebase
The combined tree measures 340754-340770 B across builds, below the
341489 B baseline main recorded for the composer stack; pin 340810 B
(max observed + jitter headroom) so the ratchet reflects reality.
* fix(ui): unexport the test-support fake gateway client (knip dead export)
* feat(skills): expose the collection review as a single cron-driven run
The weekly Skill Workshop collection review lived in a bespoke gateway
maintenance loop with its own interval, due-state check, and gateway admission
wrapper. Replace it with `runSkillCollectionReviewForAgent`, one run that keeps
the workspace claim, identity check, and status recording and reports
`{ status, summary }` to its caller. The cron timer that will own the schedule
already runs under gateway root work admission, so the extra wrapper and its
drain test go away.
* feat(cron): schedule the skill collection review as a system-owned job
Fold the weekly Skill Workshop review into cron the same way the heartbeat
monitor is: a `skillCollectionReview` payload kind, one system-owned job per
workspace (first agent of each canonical workspace) reconciled at gateway
startup, enabled only when the workshop runs in `auto` mode, on a 7-day
`every` schedule with the heartbeat phase spread. The timer invokes the
review runner directly; cron clients cannot create, patch, or remove
system-owned payloads, and the job shows up in `openclaw cron list`.
* fix(cron): warn when skill reviews are disabled
* fix(cron): preserve skill review failure details
* fix(cron): narrow serialized payload variants
* test(cron): tighten skill review coverage
* fix(gateway): reconcile skill reviews on config reload
* fix(cron): fence revoked skill reviews
* fix(cron): satisfy system payload type coverage
* fix(ui): protect system-owned cron jobs
* test(gateway): prove review revocation fence
* fix(gateway): revoke skill reviews before config publish
* improve(control-ui): align attached composer queue with current main
* fix(ui): preserve attached queue state
* improve(ui): refine composer interactions
* fix(ui): stabilize composer skill references
* fix(ui): restore composer queue and override states
* fix(ui): refine composer model and permission pickers
* fix(ui): unify composer stack borders and underlaps
* fix(ui): merge context compaction control
* fix(ui): polish new-session composer states
* fix(ui): retire removed composer contracts
* test(ui): assert model trigger loading skeleton
* fix(ui): complete composer review round two
* fix(ui): bound transcript under composer stack
* fix(ui): keep model picker open during refresh
* Revert "fix(ui): bound transcript under composer stack"
This reverts commit 4cffa4314f.
* revert(ui): restore transcript clip above composer
* fix(ui): lightly underlap transcript behind composer
* fix(ui): fade transcript beneath composer stack
* fix(ui): surface dictation failures in composer
* fix(ui): deepen transcript underlap safely
* fix(ui): move voice availability alerts into picker
* fix(ui): span transcript fade beneath stack corners
* fix(ui): consume dictation hold click tail
* test(ui): cover unavailable dictation hold tail
* fix(ui): anchor microphone hover reveal
* fix(ui): keep microphone picker stable during hold
* fix(ui): align dictation actions with composer send
* fix(ui): release dictation composer before remote close
* fix(ui): stabilize model picker during catalog refresh
* fix(ui): unify switch accent colors
* fix(ui): accelerate hold to dictate
* feat(ui): shimmer dictation listening status
* fix(ui): make dictation shimmer seamless
* fix(ui): reconcile composer stack with current main
* test(ui): drop obsolete reasoning reset expectation
* fix(ui): reconcile queue rebase with current main
* test(ui): align composer controls with current main
* fix(ui): reconcile composer rebase checks
* style(ui): format dictation control test
* fix(ui): keep model catalog state acyclic
* fix(ui): align composer e2e contracts
* test(ui): align full composer e2e suite
* test(ui): align remaining composer e2e contracts
* fix(ui): stabilize queued message editing
* test(ui): select queued edit text with a portable chord
Control+A is caret-to-line-start on macOS textareas, so the edit tests
inserted at position 0 and saved concatenated text on Mac rigs while
Linux CI selected-all and passed. ControlOrMeta+A selects on every
platform.
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* feat(cron): link chat notifications to Control UI run inspection
When gateway.publicOrigin is configured and the Control UI is enabled,
channel-delivered automation and task notifications append an
'Inspect: <url>' line so operators can click straight to the run record:
- cron failure alerts and command/script completion announcements link to
/automations?job=<id>&run=cron:<id>:<startedAt> (appended after summary
redaction so the redactor cannot strip the link)
- isolated agent run completions link to the run's exact session route
(appended only after silent/NO_REPLY suppression, immutably, so a
suppressed run cannot become a visible announcement)
- direct-channel task terminal notifications link to the task's child
session; session-queued events stay link-free
- the automations page adopts ?job=&run= deep links (loader search pass-
through, one-shot adoption after jobs load, History tab + run highlight)
No new config surface: the URL derives from the existing
gateway.publicOrigin + gateway.controlUi owner in control-ui-link-base.
* fix(cron): match linked runs by execution id and dedupe delivery runtime type
Live verification caught that ledger run ids are public run ids (receipt
UUIDs, manual:<...>) while notifications embed the execution id
(cron:<jobId>:<startedAtMs>), so the linked-run highlight never matched:
the automations page now resolves the execution id against the entry's
recorded runAtMs, with regression tests for both id spaces.
Also: move the isolated-delivery inspection-link append into the payload
normalization owner (keeps delivery-dispatch under the max-lines limit),
and replace the duplicate TaskRegistryDeliveryRuntime declaration in
task-registry.test-support with a type-only import of the canonical type
(the duplicate hid the new optional resolver from the tsgo test shard).
* fix(gateway): permission modes work on rootless sessions via workspace default root
Session permission modes hard-required a recorded sessionRoot, which only the
sessions.create RPC ever records. Channel sessions, pre-#124909 rows, and
exec-node sessions are rootless, so runs failed closed with 'session permission
mode requires a recorded session root' (rows poisoned before the #128149 guards
were permanently bricked) and operators could not set a mode on channel
sessions at all.
The mode semantics are root-free; every containment consumer already falls
back to the workspace, and the remote worker launcher already derives its
containment root from placement.remoteWorkspaceDir. Make that the canonical
rule: a recorded sessionRoot pins worktree/explicit-cwd boundaries, and a
rootless session's boundary is the agent's canonical workspace resolved where
the run is prepared.
- embedded runner + compaction: default policy root to realpath(workspace)
instead of throwing/dropping the mode; bricked rows self-heal
- gateway: delete the patch/create/reset rejection guards and
session-permission-policy.ts entirely
- codex plugin: helpers take a required defaultRoot (agent workspace fact,
never a requested thread cwd); delete the four root throws; sandbox context
for conversation bindings now derives from the agent workspace
- ui: drop the rootless-rejection surface from the permission picker
- docs: permission-modes boundary contract updated
* fix(agents): split workspace path identity out of the kysely state store
Heals main: #116650 made memory-host-sdk/dreaming.ts import
resolveWorkspaceStateIdentity from workspace-state-store, statically pulling
kysely into the memory-core doctor-contract closure and failing the
doctor-contract-closure-guard lane on origin/main. The identity helpers are
pure path/crypto code; move them to workspace-state-identity.ts and repoint
all importers (dreaming, event-store, onboarding recommendations, workspace
migrations, legacy state).
* fix(ui): drop the unused permission picker sessionRoot prop and dead i18n keys
check-prod-types caught the New Session dialog still passing the removed
sessionRoot picker prop; the prop and the permissionControls.rootLabel /
sessionRoot strings have had no consumers since #124301.
* fix(codex): resolve rootless permission boundaries from agent config, not bind cwd
ClawSweeper P1: /codex bind --cwd stores the requested path as the binding
workspaceDir, which the previous commit then trusted as defaultRoot — letting
a bound thread's requested cwd become (and widen) the rootless permission
boundary. resolveConversationAppServerRuntime now owns the fact: it resolves
the selected agent's canonical workspace from config (falling back to the
plugin's configured default workspace), and the caller-supplied
agentWorkspaceDir parameter chain is deleted so no call site can pass a
tainted value. Recovery of recorded roots is unchanged (pinned roots stay
pinned); the moved-history test now proves the bind-cwd clamp lands on the
agent workspace.
* improve(ui): fold the sidebar update action into the Inbox
The footer update circle duplicated the Inbox's update entry: same
attention state, same dismissal, one extra permanently-tinted control
plus layout shims reserving space for it. The update now surfaces only
as an Inbox entry — badge for attention, full action row (update, hold,
review, dismiss-until-restart) in the panel.
Deletes the footer button, its hover-dismiss, sidebar-footer-update.css,
and the 80px/88px width compensations; moves the floating-cluster rules
to sidebar-attention-floating.css and the build-chip text rules next to
their base class in layout.css. Removes the dead startSidebarUpdateAttention
shortcut and two orphaned i18n keys.
* fix(ui): integrate #129920 top-left alignment with the Inbox-only footer
Ports the web-chrome floating-cluster geometry to sidebar-attention-floating.css
and points the titlebar alignment test at the always-present Inbox button; the
update no longer changes footer geometry, so its scenario plumbing goes.
* chore(control-ui): vendor Space Grotesk and Lora webfont subsets
Adds the woff2 faces the Absolutely theme paints in, plus the stylesheet that
declares them. Both families are SIL Open Font License 1.1 and ship unmodified,
with their upstream license text beside the files as the license requires.
Latin and latin-ext subsets only, 184 KB total. Serving these from the gateway
rather than a font CDN keeps font-src 'self' intact, avoids a third-party
request on every load, and works on an offline or LAN-only gateway.
The stylesheet is a static asset rather than a bundled import: it is linked at
runtime only while a theme that declares faces is active, so it stays out of
the startup CSS budget and out of every other theme's load.
* feat(control-ui): add the Absolutely theme with self-hosted typography
Adds a fourth built-in Control UI theme: terracotta clay on warm graphite in
dark mode, burnt clay on ivory in light mode, with its own typography — Space
Grotesk for chrome and Lora for chat prose.
Typography is theme-owned rather than global. A new --font-chat token defaults
to --font-body, so every other theme is byte-for-byte unchanged, and the face
declarations live in a static stylesheet that the theme applier links only
while a theme that declares fonts is active. Themes without declared faces
issue no font request at all.
The faces themselves are vendored in the preceding commit.
Also fixes two defects found while building it:
- Theme cards overflowed their border on long labels: the grid's 1fr label
track floors at min-content, so a long name pushed the trailing column past
the card edge. The track is now minmax(0, 1fr) with an ellipsis, which also
covers imported tweakcn labels. The selected-state checkmark that made the
overflow visible is gone; the accent border, tint, and ring already carry
selection, and aria-pressed owns the semantics.
- index.html's first-paint bootstrap only knew claw/knot/dash, so any new theme
flashed the default palette before the app corrected it. It now resolves the
new theme and has a matching pre-paint background, with mount-fallback
coverage for both modes.
Startup JS grows 198 B for the theme wiring, appearance option, locale
strings, and font loader, which fits the existing ratchet tolerance, so no
budget baseline moves. Startup CSS stays within its 45 KiB ceiling, which is
why the faces are a linked static asset rather than a bundled import.
* style(control-ui): format the vendored font stylesheet
The generated @font-face block kept its upstream single-line unicode-range
declarations, which oxfmt wraps. Repo-wide `pnpm format:check` is clean.
* test(control-ui): type the chat runId in the typography e2e
Reuses the chat-flow requireRecord/requireString helpers instead of asserting
the params shape, so runId is a real string rather than an unknown coerced by
String() (oxlint no-base-to-string).
* fix(control-ui): resolve theme fonts against the configured UI mount
The theme font stylesheet and its faces used root-absolute URLs, so a gateway
served under a configured Control UI base path (gateway.controlUi.basePath,
for example /openclaw) fetched them from the wrong origin path. Both requests
404ed there and Absolutely silently fell back to system fonts while its palette
still applied, which reads as "the theme looks a bit off" rather than as a
broken asset.
The link href now goes through inferControlUiPublicAssetPath, the same helper
plugin art and provider icons already use, and the stylesheet's own url()
references are relative to it, so both levels follow the mount without the
loader knowing the base path. Adds an e2e case under /openclaw asserting the
resolved href and that the browser fetches below the mount; it fails on the
previous code with the root-absolute path.
Reported by ClawSweeper review on #129885.
* test(slack): stop racing the aborted read request in the Web API bound test
`bounds dedicated reads without timing out shared clients` asserted the mock
server received exactly two requests, but the read client aborts at 20ms and
the server records on arrival, so whether the aborted request lands first is a
race. It failed on a loaded CI runner with 1 received.
The bound is still asserted directly (read rejects, shared resolves); the count
now only requires the shared client's arrival, and checks its token so the
dedicated client is still proven not to have carried the call.
Unrelated to this PR's theme work; fixed here rather than left red.
* fix(models): stop models.list from silently collapsing to the harness default
Explicit models.list refresh reads now run to completion instead of racing
the 750ms browse deadline, and implicit reads that do hit the deadline fall
back to the published prepared catalog instead of returning an empty
snapshot that the Codex harness augmentation refilled with only the default
model. The Control UI model picker now revalidates silently in the
background: a warm catalog stays rendered and interactive, and the
'Refreshing models…' state is deleted end to end (chat + New Session).
* test(ui): prove the model picker stays interactive during background refresh
* test(ui): give the shared chat-pane test host a model catalog
The background-revalidation change reads chatModelCatalog.length in
refreshPageChat; createTestChatPane omitted the field, breaking the
chat-pane lifecycle suites on CI.
* improve(control-ui): align composer invocations with current main
* fix(ui): close composer invocation regressions
* test(ui): follow grouped slash hint rule
* fix(ui): preserve reasoning reset cleanup after rebase
* fix(ui): keep checkbox capability rows switch-free
The unified capability toggle row embedded a wa-switch even in checkbox
mode, reintroducing a hidden-but-real switch control inside the
new-session composer (breaking the rail-owns-switches contract asserted
by new-session-page.places e2e) and an aria-hidden focusable control.
Checkbox rows rely on the dropdown item's own checked state.
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Collapsed, the composer progress card's hover highlight painted only the
<summary> band, leaving slivers of the resting background in the underlap
strip and the arcs beside the composer's rounded corners. Move the
collapsed hover fill to the card container (clipped to the full rounded
shape, extending under the composer) and keep the summary-scoped fill for
the expanded state.
Prevent delayed rewind, branch-switch, and fork completions from overwriting replacement chat state after reconnects while preserving canonical history reconciliation.
Refs #128617.
Reviewed-by: @shakkernerd
Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com>