* fix(ui): highlight source code in chat and session diffs
* test(ui): isolate roster bootstrap from slug lookup
* fix(ui): preserve both languages in renamed diffs
* perf(control-ui): load built-in theme palettes on demand
Every built-in theme's tokens shipped in the startup stylesheet, so the default
path downloaded six palettes it never painted and each new theme taxed everyone.
That is what pushed the startup CSS ceiling from 45 to 47 KiB when Tide, Beacon,
and Phosphor landed.
Moves the twelve non-default palettes into public/themes/<family>.css, one file
per family covering both modes. Claw stays inline because its tokens are the
:root defaults, so the default path loses nothing and gains the bytes back.
The first-paint story this needed: index.html's boot script now links the active
family's palette during head parsing, which makes it render-blocking exactly
like the app stylesheet, so a persisted theme paints its own colours on the
first frame instead of flashing the default. The href is built from the mount
prefix the gateway already stamps on <html>, so it follows a configured Control
UI base path without the script having to know one. theme.ts keeps the link
correct when the theme changes at runtime, reusing the helper the webfont
stylesheets already use.
The nested resolve-theme ternary became a family table in the same script, since
it now picks an asset as well as a data-theme value.
startup CSS 45.8 -> 42.2 KiB gzip, below the 44.3 KiB it measured before the
three themes landed; ceiling restored 47 -> 45 KiB
base.css 64.0 -> 35.8 KiB raw
Adds a regression test that blocks every bundle script and asserts the palette
still applies, so moving this back into the app bundle fails instead of silently
reintroducing the flash. Verified it catches that: with the boot-script link
removed the assertion reports `expected null to be '/themes/tide.css'`.
* fix(control-ui): publish themes after their palettes load
* fix(control-ui): clean up palette completion listeners
* refactor(control-ui): consolidate theme name resolution
Restore the saved form and draft tracking when Raw returns to the original bytes, so switching back to Form and editing another field cannot resubmit discarded values. Reuse the existing draft reset while preserving Raw authority and conflict status.
* feat(ui): save appearance preferences per user profile
When a Control UI connection is bound to an authenticated user profile
(trusted-proxy, Tailscale, GitHub identity), theme, theme mode, and accent
become per-profile: stored in the existing user_preferences KV store via
users.prefs.set, overriding gateway-wide ui.prefs, with live cross-device
updates over a new additive users.prefs.changed event scoped to the same
merged profile. Restore default deletes only the profile key and falls back
to the gateway-wide value. talk.config projects the caller's profile accent
for native clients. Profile-less connections (token/password/none auth)
keep the existing gateway-config behavior byte-identically.
Release-note context: on multi-person gateways appearance choices are now
personal and follow you across devices; an admin restyling their own UI no
longer reskins the whole team.
* chore(protocol): allowlist users.prefs.changed for mobile clients
iOS and Android resolve the profile accent through talk.config on connect
and config refresh; live per-profile appearance push on natives is a named
follow-up.
* chore(protocol): regenerate Swift/Kotlin models for users.prefs.changed
* fix(ui): honor explicit defaults and identity switches for profile appearance
Addresses the three ClawSweeper review findings:
- resetValue for profile-bound appearance keys is the deletion fallback
(gateway value), so an explicit product-default selection persists as a
profile write instead of being misclassified as a reset.
- An identity switch between two known scopes forces a full appearance
reconcile and clears appearance keys the returning identity never set,
so a shared browser cannot keep the previous profile's look (boot keeps
the last-seen shortcut).
- talk.config joins the profile-dependent dispatch gate so a pending
GitHub identity sync completes before the profile accent is projected;
token clients pass through untouched.
* chore(ui): satisfy assertion-safety ratchet and docs formatting
* refactor(ui): extract server-prefs storage primitives
server-prefs.ts crossed the 700-line cap after the review fixes; the
stateless localStorage persistence primitives move to
server-prefs-storage.ts (no behavior change). Shrink-prunes the
assertion-safety baseline for the moved casts.
* fix(ui): keep imported custom themes out of profile storage
Custom palettes are browser-local by contract, so a profile must never
carry theme=custom to a browser that cannot render it. The wire contract
drops custom from the storable theme values (readers self-heal any stored
value), a profile-bound custom selection stays retained browser-local
(including the offline-queued path), and the exhaustive theme test now
encodes the exception.
* fix(webchat): render CLI harness-injected turns as collapsed context, not operator bubbles
Claude Code writes harness-generated user-role turns (Skill instruction
bodies, continuation summaries) into its native transcript with
isMeta/isCompactSummary flags. The claude-cli history importer dropped
that provenance, so webchat rendered those turns as right-aligned
operator bubbles the operator never sent.
Record the provenance at the import boundary as structured
InputProvenance (internal_system / cli_harness_context) — the same
contract the embedded runner uses to keep internal system prompts out of
operator bubbles — and teach the system-notice path to render such turns
as a collapsed "Injected context" notice that does not start an operator
turn.
Regression coverage: importer unit test (provenance recorded only for
flagged rows), live chat.history gateway RPC test (provenance survives
merge + display projection), and a chat-thread UI test (injected turn
becomes a collapsed notice while a real operator message keeps its
bubble). Before/after webchat captures via
scripts/capture-injected-turn-notice-proof.mts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Publish openclaw/fix-cli-injected-turns-rendering-as-operator-messages
OpenClaw-Publication: 62f03fec-179b-4c5e-9423-4df80809de41
* chore: drop changelog entry; CHANGELOG.md is release-generated
Release-note context lives in the PR body per repo policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: register injected-turn proof capture as ui:proof script
Knip's full-tree unused-file scan requires capture scripts to be
package.json entry points, matching the sibling ui:proof:* captures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gateway): cover isVisibleInTranscriptOnly harness rows; absorb provenance helper
Claude Code groups isMeta, isCompactSummary, and isVisibleInTranscriptOnly
as its own "not a real operator turn" predicate (verified in the v2.1.246
bundle); import all three through one canonical predicate. Inline the
one-use provenance literal at the importer, returning input-provenance.ts
to its main state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): recover dashboard finals after message-less terminal
* fix(ui): harden dashboard final recovery
* test(ui): coalesce terminal history recovery
* fix(ui): refresh finals after stale history loads
* test(ui): narrow final recovery fixture status
* fix(ui): preserve final refresh after pending reload
Each theme's --bg is copied by hand into two places outside base.css, because
both have to paint before the app stylesheet is parsed: index.html's pre-paint
block, which fills the page during first paint, and the Appearance preview
chips, which are deliberately theme-invariant and so cannot read the live token.
Nothing tied those copies to the palette. Darkening Absolutely moved --bg and
left both behind; only a pre-paint assertion that happened to hardcode the old
value caught one of them. It is the same shape as the two defects review found
in the theme work — a hand-maintained list extended for new themes but not
exhaustively — and the third time is enough.
Derives the expected values from base.css and asserts both mirrors for every
palette (12 themed pre-paint rules plus the default), so a palette edit cannot
leave either copy stale. Verified by mutating each mirror:
tide: pre-paint #123456 != --bg #10151b
beacon: chip #abcdef != --bg #000000
Test-only; no production change.
* fix(ui): distinguish inherited model defaults from session pins
The Gateway session projection collapsed inherited agent defaults and
persisted conversation pins into the same effective model fields, so the
Control UI picker marked a row Default while its footer simultaneously
called it a session override and offered a meaningless reset.
Project the authoritative persisted provenance instead: the session owner
resolves `modelOverrideSource` (normalizing entries written before source
tracking), `buildGatewaySessionRow` and the session change event carry the
additive `"user" | "auto" | null` protocol field, and the Control UI treats
only `"user"` as a conversation pin.
Closes#120752
* test(ui): scope model provenance to active pane
* fix(ui): derive the model picker's inherited sentinel from recorded provenance
Deriving "inherits the agent default" from the effective model matching the
default meant a session pinned to model X stopped reading as pinned once the
agent default moved to X. The picker then rendered the default row as already
selected, so clicking it matched its own commit value and was swallowed, leaving
the stored pin unreachable from the UI.
Project the session store's existing modelOverrideSource onto the gateway row and
let the UI resolve a closed selection source (inherited/pinned/fallback) from it,
falling back to the old equality guess only for gateways too old to send the
marker. Preserve the null tombstone in reconcile so a cleared pin stays
distinguishable from that silence.
* test(ui): pin the model picker's digit-key ownership
The numbered keycaps and the digit handler are paired by search focus, but the
guard that keeps digits in the search input had no coverage outside the browser
e2e lane. Add a unit regression for both halves and record the contract at the
numbering site, where reviewers keep reading the two gates as unrelated.
* fix(ui): keep session model selection provenance authoritative
Project saved model provenance through Gateway rows and events, retire temporary UI claims at their owner, and use canonical refresh after slash and status model changes. Preserve model intent through rollback and deletion without retaining a public cache setter.
Co-authored-by: Vyctor H. Brzezowski <krzyszchweski@gmail.com>
* refactor(ui): keep recovery with scoped session operations
Preserve recovery notifications, visible errors, and connection checks while keeping the optimistic mutation owner within its module limit.
Co-authored-by: Vyctor H. Brzezowski <krzyszchweski@gmail.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Keep cached dashboards visible when their session-list refresh fails, show the existing stale-data notice, and retry the current agent query. Cover connected refresh failures, first-load recovery, and retired scope callbacks.
Prevents chat responsive CI from treating two transient matching frames as a settled layout. The browser fixture now waits for a short stable geometry window and includes a real delayed-layout regression.
Refs #130112.
#127114 replaced the exact-revoke-count attachment preview test with the
invariant-based proof in new-session-page.attachment-url-lifecycle.e2e.test.ts,
deleting the old block from new-session-page.prompt-attachments.e2e.test.ts.
#124301 branched before that deletion, so its merge resurrected the stale copy
and main shipped two tests with the same name.
The resurrected copy asserts literal cumulative revokeObjectURL counts. Those
counts are not an invariant: on remount the durable IndexedDB draft restore
(draft-persistence.ts restoreScope) can re-hydrate the attachment into a fresh
id and object URL, then release the copy the in-memory route handoff carried,
producing one extra matched createObjectURL/revokeObjectURL pair. Reproduced
with stack capture under 6x CDP CPU throttling: revoked reached 3 before the
Remove click and 4 after, which is the "expected 4 to be 3" seen in CI.
No leak and no double-release: releaseChatAttachmentPayload deletes the payload
map entry before revoking, so every URL is created once and revoked once. The
surviving lifecycle test asserts exactly that (active URLs, duplicateRevocations
and unknownRevocations both zero) and pins the hydration step deterministically,
so deleting the duplicate loses no coverage.
* fix(ui): restore person grouping for multiple session owners
Use the live session-owner roster for Person grouping and People sorting instead of the connection-time identity policy. Preserve preferences while the roster is unknown and retain single-owner dormancy across sidebar and Sessions page.
* docs(ui): match the owner sorting label
* refactor(ui): reuse session owner and menu selection paths
Absolutely shipped as the lightest dark theme in the set by a wide margin: its
--bg #262624 sat well above Dash #1a1210, Claw #0e1015, and Knot #080808, which
left the clay accent and the Lora prose with less separation than the palette
was designed for.
Steps the whole ramp down one notch (--bg #262624 -> #1c1c1a, with the card,
elevated, hover, chrome, and border tiers following) and keeps the warm graphite
cast. Text and accent tokens are untouched, so this is purely a surface change.
Every measured pairing improved rather than regressed:
worst text pair 4.68 -> 5.42:1 (in-browser, --muted on --bg-muted)
accent on --card 4.47 -> 5.05:1 (AA where it previously was not)
accent on --bg 4.86 -> 5.47:1
danger on its own subtle tint over --bg-muted 3.76 -> 4.32:1
The first-paint background in index.html and the Appearance preview chip mirror
--bg by hand, so both move with it; the pre-paint assertion in mount-fallback
covers that they stay in lockstep.
* chore(control-ui): vendor JetBrains Mono and Atkinson Hyperlegible Next
Adds the woff2 faces for the Beacon and Phosphor themes plus the stylesheets
that declare them. Both families are SIL Open Font License 1.1 and ship
unmodified with their upstream license text, as the license requires.
Atkinson Hyperlegible Next was drawn by the Braille Institute for low vision:
its letterforms disambiguate the pairs that blur first at low acuity (I/l/1,
O/0, rn/m), which is why it belongs to the accessibility theme specifically.
base.css has always named JetBrains Mono first in --mono, but nothing shipped
it, so code blocks rendered in it only for people who happened to have it
installed locally. Phosphor makes that deterministic.
Latin and latin-ext subsets only, 195 KB. Served from the gateway rather than a
font CDN so font-src 'self' holds, no third-party request happens on load, and
an offline or LAN-only gateway still renders correctly. The url() references are
relative to their stylesheet so they follow a configured Control UI mount.
* feat(control-ui): add the Tide, Beacon, and Phosphor themes
Three built-in themes, each filling a gap the existing four share rather than
adding a fifth variation on them. Every shipped accent today is warm — coral,
crimson, chocolate, clay — and the only cool token anywhere is Claw's teal
secondary.
Tide is the cool one: steel cyan on deep slate, deep teal-blue on cool paper.
It deliberately ships no webfonts, so it costs nothing beyond its tokens.
Beacon is the accessibility one. It targets WCAG AAA (7:1) rather than the AA
4.5:1 floor the other themes hold, for low vision, direct sunlight, projectors,
and poor panels, and sets everything in Atkinson Hyperlegible Next. Reaching AAA
forced one deliberate break from the status-token convention: elsewhere each
--x-subtle is --x at 8%, but a label on a tint of its own hue caps achievable
contrast because the tint pulls the background toward the text. Beacon tints
neutrally and lets the label carry the contrast. Its focus ring is opaque and
3px for the same reason — a translucent ring is the first thing to vanish on a
bad panel. The contrast guardrail now enforces 7:1 for Beacon specifically, so a
later palette edit cannot quietly demote it to an ordinary dark theme.
Phosphor is the terminal one: phosphor green on green-cast black with the whole
surface, chat prose included, in JetBrains Mono. Monospace prose is a character
choice for an operator console, not a readability claim, which is why it is
opt-in.
Every palette was solved against the real guardrail before any CSS was written.
Worst text pairing per theme: Tide 6.12:1 dark / 4.63:1 light, Beacon 10.29:1 /
9.72:1, Phosphor 6.69:1 / 4.76:1. Worst status label on its own tint: Beacon
7.05:1 / 7.02:1, the others above 4.6:1.
Budgets: the startup CSS ceiling moves 45 -> 47 KiB and the JS ratchet baseline
moves 523 B. Every built-in theme's tokens ship in the startup sheet, so each
new theme costs ~0.5 KiB gzip whether or not anyone selects it. Moving per-theme
palettes to lazily linked stylesheets (as theme webfonts already are) would take
this back under the old ceiling, but it needs a first-paint story first: a late
palette flashes default colors where a late font only swaps.
* fix(control-ui): use Beacon's destructive foreground in chat confirmations
The chat confirmation button paints from --danger under --media-foreground, a
theme-invariant white, and each dark palette opts out of that pairing through a
selector list in chat/grouped.css. Beacon defines a deliberately light --danger
(#ffabab, chosen so status labels clear AAA) but was missing from that list, so
its confirm button rendered white on pale pink at 1.80:1 — on the theme whose
entire promise is contrast.
Adds Beacon to the list, which routes it to --destructive over
--destructive-foreground at 11.67:1.
Also adds a guardrail for the bug class, since the selector list has to be
extended by hand for every new dark theme and I have now missed it once. The
test reads membership back out of grouped.css rather than restating it, resolves
each theme's effective (background, color) pair, and holds it to that theme's
floor. It reproduces this defect at 1.80:1 against the pre-fix stylesheet.
Reported by ClawSweeper review on #130232.
* fix(ui): persist session owner filters
Keep owner and involving-me filters across reloads and transient ownership metadata gaps, scoped to the authenticated user and gateway.
Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Worked on by:
- @Solvely-Colin
* refactor(ui): keep owner filter helpers within lint limits
Move persisted owner-filter state into a reactive controller and proof-only browser helpers into the existing visual support module without changing behavior.
Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Worked on by:
- @Solvely-Colin
* test(ui): restore ownership proof dependencies
Keep the shared proof artifact directory and filesystem helper available to the existing ownership screenshot and video scenarios after the lint-limit extraction.
Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Worked on by:
- @Solvely-Colin
* refactor(ui): harden owner filter persistence
Make the reload assertion consume only the reloaded document request log, retire stored owners only from an authoritative facet, preserve a clear-filter escape hatch while metadata is unresolved, and tolerate unavailable browser storage.
Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Worked on by:
- @Solvely-Colin
* fix(ui): scope owner facets to authenticated identity
Hold owner-facet validation until the new identity filter request settles and advances the canonical roster revision, preventing stale options from deleting another user’s saved preference.
Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Worked on by:
- @Solvely-Colin
---------
Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Co-authored-by: Colin Johnson <colin@solvely.net>
* fix(ui): collapse side panel after final tab closes
* fix(ui): preserve side panel owner state
* fix(ui): preserve projected panel closure
* fix(ui): align projected panel close contract
* fix(infra): prevent dollar-pattern injection in home dir tilde expansion
String.replace interprets dollar-amp/dollar-1/dollar-backtick in the
replacement string. When the home directory contains these sequences
(e.g. a username with a dollar sign), tilde expansion via
.replace(/^~/, fallbackHome) corrupts the path silently.
Use a function replacement so the home path is treated literally,
matching the pattern already fixed in terminal-core/display-string (#111398).
Two call sites: resolveRawHomeDir and expandHomePrefix.
* fix(daemon): prevent dollar-pattern injection in state dir tilde expansion
Address review rank-up: the daemon state-path expansion deliberately does
not use the core helper and still passed home as a string replacement.
Apply the callback form here too and add a literal-dollar regression to
the existing service-env suite (fails on the string form, passes with
the callback).
* fix(launcher): keep literal $ patterns when expanding tilde OPENCLAW_HOME
* fix(ui): keep literal $ patterns in local media tilde expansion
* test(ui): prove literal-$ tilde local media preview through Control UI e2e
* test(ui): align literal-dollar media proof with compact attachment contract
Preserve the current authenticated metadata and ticket-scoped download behavior while exercising the real Chromium Control UI under a literal-dollar home.
Co-authored-by: liyuanbin <li.yuanbin1@xydigit.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>