* fix(ui): keep Control UI device identity working on plain-HTTP origins
@noble/ed25519 defaults its SHA-512 provider to crypto.subtle, which
browsers gate to secure contexts, so device identity silently vanished
on http:// LAN dashboards and connects fell back to shared-credential
auth with no pairing. Wire a lazy pure-JS @noble/hashes fallback for
SHA-512 and the fingerprint SHA-256, and drop the isSecureContext gate
in the connect path. Secure contexts keep the platform digests and pay
no startup bytes: the fallback loads as its own lazy chunk, kept out of
the gateway-runtime startup chunk on purpose.
* test(ui): cover device identity minting and signing without crypto.subtle
New jsdom regression suite fails on pre-fix code (subtle-less crypto stub
with getRandomValues, which real insecure contexts keep). Rewrites the
gateway connect tests that previously asserted the device-less insecure
fallback: an insecure context now attaches a device identity.
* docs(web): plain-HTTP dashboards now pair with a device identity
The signing key never crosses the wire, so HTTP+pairing is strictly
stronger than the old HTTP token-only fallback; HTTPS (Tailscale Serve)
stays the recommendation for transport privacy.
* fix(ui): drop unnecessary boolean literal compare in secure-context timing meta
* test(ui): declare device.id on the connect-frame test shape
* test(ui): split the subtle-less scope-upgrade e2e into the two real invariants
Without crypto.subtle the browser can now sign, so the banner offers the
explicit admin upgrade; manual-only guidance is reserved for browsers that
cannot mint an identity at all (no WebCrypto RNG). Also corrects the
connect-path comment: blocked storage yields an ephemeral identity, only a
failed mint degrades device-less.
* fix(ui): address review findings on the HTTP device-identity path
- Storage-blocked pages keep one stable in-memory identity per page
lifetime instead of minting a fresh unpaired key on every reconnect,
and a write-rejecting store no longer fails the mint (regression tests
bite pre-fix).
- Connect timing now reports the real browser secure-context fact via a
shared browserSecureContext() helper instead of inferring it from
device-identity presence.
- Docs state the accepted trusted-proxy contract: browsers attach a
device identity on every origin, so first connects follow the standard
pairing flow (deviceAutoApprove or a one-time approval); device-less
admission remains only for browsers that cannot mint an identity.
* refactor(ui): trim the connect-path additions under the max-lines cap
* refactor(gateway): delete the retired Control UI device-auth migration window
The one-time remediation window for the retired
gateway.controlUi.dangerouslyDisableDeviceAuth break-glass threaded a pending
state machine through the connect pipeline, startup bootstrap, lifecycle,
request context, device management, security audit, the hello frame, and a
Control UI banner flow — steady-state runtime carrying a retired-shape shim
the architecture reserves for doctor. The window is closed: the retired key
is now fully inert, doctor still detects and removes it, and a browser that
never completed the migration pairs through the normal device flow (one
approval, no data loss).
Deleted with it: the config-machine-state import of the legacy flag, the
hello deviceAuthMigration field (optional; clients validate envelopes only,
so older gateways emitting it stay compatible), the migration-bound device
management authz states, the requireNoPairingCapableOperator approval mode,
the effective-operator pairing event emitter whose only subscriber was the
migration completion, and the Control UI banner, loader, overlay wiring,
i18n strings, and e2e scenario support. Swift and Kotlin protocol models
regenerated.
Live-verified on an isolated gateway with the retired key present in config:
clean boot with no migration warning, silent CLI pairing and silent local
scope widening unaffected, and doctor reporting the key as retired-and-inert.
Net -1111 production LOC.
* fix(ci): finish migration-window cleanup surfaced by the gates
The doctor migration for the retired key kept its "Preserved for remediation"
change text and describe; both now state plain removal, with the retired
tests updated. peekStoredDeviceIdentityId lost its only production consumer
with the deleted banner loader and is removed with its test mock. Also fixes
the unrelated no-unnecessary-boolean-literal-compare lint break that #124636
landed on main in scripts/check-changed.mts (truthiness is equivalent for the
boolean-or-undefined TTY probe).
* fix(ui): drop the retired-key device-auth reader from the security summary
Review findings on the migration-window removal: the Control UI security
summary still derived its "Device auth" row from the retired
dangerouslyDisableDeviceAuth key, rendering device auth as disabled on
configurations that merely retain the inert key. Device auth is now
unconditionally enforced, so the row and its derivation are removed rather
than pinned to a constant. The build-admission test also tracks and removes
its temporary device-identity databases after each run.
* feat(protocol): add named session icon glyphs
* feat(ui): add session glyph and custom emoji picker
* docs: describe named and custom session icons
* fix(ui): give the custom emoji input an accessible name
ClawSweeper P2: the input had no label relationship; assistive tech announced an unnamed edit control.
* fix(protocol): guard the v-flag icon regex for browser module loads
ClawSweeper P1: the picker's static import evaluates this module in the browser; pre-Unicode-Sets engines threw at module scope and took down the session menu. Lazy capability-guarded construction; such engines fall back to the grapheme heuristic as client pre-validation while the Gateway keeps exact RGI validation.
* Control UI: unify chat rails in a tabbed panel
* fix(ui): compact chat side panel navigation
* Polish rail separators around the active tab
* Soften rail tab separators
* Inset the rail tab strip from the resize handle
* Remove the rail tab strip bottom rule
* Add compact close controls to rail tabs
* Redistribute rail tabs after closing a surface
* Restore bottom docking from the rail terminal
* Fade clipped rail tab labels at the edge
* Add drag reordering to rail tabs
* Polish terminal tab chrome
* Polish rail tab close controls
* Regularize rail tab separators
* Space rail tabs evenly
* Add feedback to inactive rail tabs
* Smooth rail tab selection
* Hide unavailable rail menu items
* Preserve browser multi-tab entry
* Support bottom-docked rail tabs
* Polish rail tab sizing
* Refine side panel actions
* Neutralize rail empty-state icons
* Separate dark rail surfaces
* Fade only clipped rail tab labels
* Compact rail header controls
* Refine the surface picker
* Raise and tighten the surface empty state
* Keep Browser and Tasks in the chat topbar
* Unify surface list cards
* Keep embedded rails inside their dock
* Reveal rail tab close actions on demand
* Show full rail labels only when clipped
* Let rail tabs use available space
* Align chat topbar surface controls
* Unify rail surface empty states
* Center rail tab glyphs
* Fade clipped rail labels smoothly
* Keep rail tab labels visible
* Tighten rail tab separator and label fade
* Keep the new-tab control beside the rail tabs
* Keep rail tab separators from reflowing on activation
* Anchor the new-tab control outside the rail tab scroll
* Scroll rail tabs at the floor instead of clipping them
* Tighten spacing between rail tabs
* Report an unusable terminal open response as a readable failure
* Keep the terminal session menu inside the panel
* Share one dock destination cluster across rails, terminal and browser
* Align the terminal mini rail with its management icons
* Tidy panel pickers, browser chrome and task detail retry
* Type the sidebar callbacks object so tsgo:ui passes
* Space out the panel picker rows
* Measure tab strip edges by rect and cancel superseded installs
* Validate every terminal session field and release unusable sessions
* Fold the terminal rail stretch rules into one
* test(ui): match rail tab checks to the tabbed panel header
Anchoring the new-tab control outside the scroll area wrapped the strip in
.side-panel__header-tabs, and every query written as a direct-child chain
started matching nothing. The assertions did not fail loudly: they compared
an empty label list, so three side-panel journeys and the background-tasks
rail went red on a selector detail rather than on behaviour.
Give the label query one named owner scoped to the panel's own header, so
Terminal and Browser strips in the panel body stay excluded while header
layout can keep moving. Freeing that width also means two short tabs now
fit where they used to be squeezed, so the closing step asserts the honest
invariant - a strip that fits again releases the fade - while the overflow
and tooltip regime stays proven earlier in the same journey.
Settle the shared side-panel helper on whichever surface renders: an empty
panel offers its type list and a populated one offers the header menu, and
probing before either existed left it waiting for a control that never
comes. Also drop a shadowed seed binding and an unused locator that oxlint
flagged in the same file.
* fix(ui): adapt session discussion slot to the unified panel layout
* test(ui): capture the bottom dock evidence with a resting divider
* fix(ui): drop the dead previousDock toggle path
* test(ui): align sibling suites with the tabbed panel decisions
* fix(ui): keep narrow-pane hides above the icon-button styling cascade
* fix(ui): create tooltip descriptions via ownerDocument
* fix(lint): merge duplicate terminal header styles and drop dead branches
* refactor(ui): extract embedded panel templates from the pane render
* refactor(ui): delete rail-era dead exports
* test(ui): repair type drift after the rebase
* fix(ui): keep the workspace toggle module-local and settle test caps
* fix(ui): give side-panel tabs their runtime and their own header actions
The tabbed side panel dropped two things the old rails owned. The discussion
tab lost its only runtime registration, so `openclaw-session-discussion` was
never defined and the tab rendered as a permanently blank box; it now loads
through the per-slot lazy runtime map like the other panel types.
Panels also have no header of their own anymore, so any action on the active
panel's content had to reach the shared header. The header now takes panel
contributed actions instead of a discussion-only open-url special case, which
brings back the side-chat "Clear thread" overflow menu — its gateway reset had
no reachable entry point at all in the embedded rail.
* test(ui): anchor the sidebar scroll proof to the panel host
The bounding contract moved from `.sidebar-column__panel` to
`.side-panel__panel`; mounting the detail panel under the removed class let it
grow instead of scroll, so the test failed for its harness rather than for the
behavior it guards.
* refactor(ui): move side-panel wiring out of the chat render monolith
`chat-pane-render.ts` crossed its line budget. The region callbacks are layout
policy, so they resolve in the layout module with the pane injecting only what
it owns, and the two panel-action producers collapse into the single header
contribution the region consumes.
* fix(ui): keep one terminal intent queue per document
A session route mounts the side-panel terminal beside the shell instance kept
for the bottom dock. Each owned a private action array over one sessionStorage
key, so their whole-array writes erased each other's intents and a freshly
mounted panel drained through a reconnect fence it never saw.
The queue, its persisted record, and the fence now belong to the document;
panels bind as executors while connected. The chat pane records a toggle intent
where it observes it, instead of parking the raw event in memory and handing it
to a panel that may not be mounted yet — a service-worker reload in that window
dropped the request, thread id and all, with nothing explaining why. That makes
the deferred-restore handshake dead weight, so it goes.
* fix(ui): let the panel that starts a terminal intent finish it
Opening a catalog terminal from a chat route mounts the side-panel terminal
right after the request, which swapped the queue's executor mid-flight and
stranded the in-flight action; the panel's own mount restore then opened a
plain session instead. The executor that began an action stays responsible for
it as long as it can still run.
* test(ui): cover closed legacy side docks
* refactor(ui): split side panel controllers
* fix(ui): stabilize embedded panel handoffs
* style(ui): format browser panel refresh
* test(ui): settle catalog prepend paint incrementally
* docs(ui): describe the unified chat side panel
* test(ui): settle catalog clock transitions
---------
Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com>
* fix(ui): add persistent lobster dismissal
* fix(ui): keep the lobster dismiss menu from scrolling its own items
The dismiss menu anchors a synthetic trigger at the raw pointer position,
and the pet always sits on the sidebar footer ledge at the bottom of the
viewport. Web Awesome caps its popup to `--auto-size-available-height`,
and its `size` middleware runs after `flip`, so the menu was shrunk in
place to 60px against 64px of content instead of moving up. The two
dismissal items then scrolled inside a 6px overflow, which shows as a
scrollbar for anyone running the system setting that always renders them.
Clamp the anchor to the viewport the way every other pointer-anchored
menu already does (session-menu.ts:264, catalog-session-menu.ts:55,
native-link-menu.ts, sidebar-menus-controller.ts:270), so the popup keeps
the room it needs and renders both items in full.
* docs(web): describe the lobster dismiss menu options
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
* feat(ui): sync browser viewport to panel
* docs(web): note browser panel follows dock size
* refactor(ui): split browser panel input handling out of the controller
* refactor(ui): keep browser panel input host interface module-local
* fix(ui): fence browser viewport sync on dock close and document change
ClawSweeper review findings: a debounced resize could fire after the dock
closed (silently resizing the agent-controlled browser), and the per-target
resize guard survived tab/document changes, suppressing a needed re-sync when
revisiting a tab an agent had resized. Gate syncViewport on the panel being
open and clear the guard in invalidateViewOperations; both paths now have
regression tests that fail pre-fix.
* feat(protocol): add portal methods and event
Bump the reviewed protocol owner-module count from 55 to 56.
* feat(gateway): add portal service and reverse proxy
* feat(agents): add portal tool
* fix(gateway): refine portal URL and proxy auth
* refactor(gateway): keep portal helper types private
* fix(gateway): declare portal transport service
* test(gateway): satisfy portal proxy lint
* test(gateway): narrow websocket payload types
* refactor(protocol): compact portal schema exports
* fix(gateway): export portal protocol types
* feat(ui): add portals page
* docs(gateway): add portals guide
* fix(gateway): dial portal targets via localhost dual-stack
Vite and other Node >=17 dev servers bind ::1 only for localhost, so a
fixed 127.0.0.1 dial 502s on the default path. Use hostname localhost
with family autoselection and rewrite Host to match.
* fix(gateway): type portal dual-stack connection
* fix: satisfy portal integration gates
* fix(gateway): isolate portal cookie jars per target
Cookies are hostname-scoped, not port-scoped, so the per-port origin
split alone let Gateway plugin-auth cookies reach agent-run targets.
Forward only cookies carrying this portal's own name prefix (stripped),
rewrite target Set-Cookie names to the prefixed form incl. the WS 101
handshake, and drop Domain attributes.
* fix(ui): detect unreachable portals behind proxied gateways
Probe the portal origin from the browser (no-cors, 4s timeout) and show
a recovery notice with the gateway-host URL instead of a dead iframe
when only the gateway port is exposed (Serve/Funnel/reverse proxy).
Docs: cookie isolation + reachability; zh-CN glossary entry.
* test(ui): satisfy portal reachability lint
* test(gateway): provide control UI request hosts
* chore(protocol): regenerate after rebase
* fix(gateway): namespace portal auth cookies by listener
* fix(gateway): scope portal token URLs to write-capable clients
The portal bearer token rides in the summary url/tokenQuery; portal.list
is operator.read and portal.changed fans out to read subscribers, so a
read-only client could harvest an openable URL. Make those fields
optional, redact them from read-scope list responses, and drop them from
every portal.changed broadcast; write/admin clients still receive them
and the UI refetches the list on change.
* docs(web): list the portals route
* fix(gateway): type portal open credentials
* docs(gateway): clarify portals PORT/PUBLIC_URL are agent-set
Opening a portal creates only the proxy listener; the agent sets PORT
and PUBLIC_URL in its own exec command, matching the portal tool
contract. Removes the implication of an automatic env handoff.
* chore(protocol): regenerate portal models
* style(gateway): format portal method-order assertions
Rebase union-merge left the portal.list assertion wrapped; oxfmt fits it
on one line.
* chore(plugin-sdk): refresh API baseline after rebase
* chore(plugin-sdk): refresh API baseline after rebase
* chore(protocol): refresh portal event order after rebase
* chore(plugin-sdk): refresh API baseline after rebase
* fix(gateway): pin portal referrer policy to no-referrer
The portal URL carries its bearer token in the query, and upstream
response headers are copied verbatim, so a target answering with
Referrer-Policy: unsafe-url could leak that URL to every third-party
origin it references. Force no-referrer after the copy and drop any
inbound Referer that still carries the token before forwarding.
* refactor(ui): unify task details in sidebar panel
* docs(ui): describe task detail sidebar
* refactor(ui): collapse onSelectTask into onOpenTaskDetail
Two names for the same open-the-panel action; rows now call the
callback directly.
* fix(ui): clear rail open-task highlight when detail slot closes
openTaskId derived from sidebarContent alone, but region close only
collapses the layout and retains content, leaving a stale
aria-current row; gate the highlight on detail-slot visibility.
* fix(ui): keep requester sessions out of subagent transcripts and let reopen retry failed lookups
A queued subagent has no child session yet; falling back to its
sessionKey rendered the requester's conversation as the task
transcript. And a failed tasks.get permanently suppressed the panel's
render-driven load; selection now clears the recorded error so
reopening retries, matching the old rail behavior.
* feat(ui): open subagent details in chat sidebar
* chore: drop changelog edit (release generation owns it)
* refactor(ui): drop duplicate close in subagent detail panel
The sidebar region header already owns a Close Details control in both
wide and narrow layouts; the panel-local X duplicated it 40px away.
* fix(ui): stop subagent transcript loader when pane presentation retires
Pane retention wipes sidebarContent directly, so the detail slot's
render-time reset can never run again; a pending refresh timer plus
incoming task events kept refetching chat.history for a hidden panel.
* docs(ui): note close-control ownership in subagent detail header
* fix(ui): break transcript renderer import cycle
* fix(ui): use shared action cursor for subagent rows
* feat(ui): commit-scoped session diff viewer with file, sync, and view menus
The Control UI session diff panel becomes a dense Amp-style viewer: per-file
menus (copy path, open file, reveal in file tree, open in editor), a Sync
Locally popover with a copyable git fetch command, view options (collapse all,
wrapping, split/unified layout), and a sticky footer that reports how far the
branch is ahead of its merge base and switches between all changes,
uncommitted work, and individual commits.
sessions.diff gains an additive scope param (all | uncommitted | commit) plus
commits, aheadCount, and mergeBase metadata. Commit-scope diffs read only the
object database, skip untracked collection, and bypass session-start baseline
filtering; unknown commits surface a typed unavailableReason. Offscreen file
bodies use content-visibility so large diffs stay responsive.
* fix(ui): satisfy session diff CI gates
* fix(gateway): fence commit-scoped session diffs to the advertised branch history
ClawSweeper found that commit-scoped sessions.diff accepted any commit resolvable in the checkout. Fence operator.read commit reads to the advertised merge-base..HEAD history and cover sibling-branch and base-history commits.
* feat(webui): auto-request notification permission on first chat send
The Control UI now asks for notification permission automatically on the first chat message you send (once per browser/origin; web push in browsers, native prompt in the macOS app; denied permission is never re-asked automatically — Settings -> Notifications stays the manual path).
* fix(webui): preserve notification prompt gesture boundary
Restrict the one-shot notification prompt to direct non-command composer sends, invoke browser permission synchronously, and keep Settings documented as the management and recovery path.
* fix(webui): exclude deferred notification sends
Require an actively dispatching browser input event before consuming the notification one-shot, so retained catalog handoffs and other deferred sends cannot suppress the first real composer prompt.
* feat(ui): edit a queued chat message in place
The pencil on a queued row, or a double-click, lifts the message and its
attachments into the composer. The row stays where it is, marked Editing,
so the position the corrected message will take is visible rather than
promised. The composer shows an edit banner with an X to cancel.
Cancelling touches storage not at all, because the row never left the
queue. Sending retires the original and hands its position to the
replacement, which lands in the same slot. While an edit is open the
drain refuses that row, so a reconnect cannot deliver the text the
operator is visibly rewriting; the queue behind it waits, which is the
same contract the held position promises.
* fix(ui): retire an edited queued row only with its replacement
The send retired the source row first and admitted the replacement second,
so a rejected store write left the operator with neither: the original gone,
the replacement never persisted, and the attachments it dropped already
released. Admission now carries the source id and swaps both rows in one
store write, so a rejected write changes nothing and the edit stays open on
the row that is still there, which is what cancelling already promises.
Filtering the source before the cap check also keeps a replacement
admissible on a full queue.
The edit token recorded only the session key, but an outbox is scoped by
session and agent, and a raw global session keeps its key while the selected
agent changes underneath it. The token now carries the agent that owned the
row and every reader validates the whole scope, so a send after a switch
cannot retire a row in the outbox it left behind.
* fix(ui): hold an edited queued row across every pane
Panes share one outbox and one drain lane while composer state stays per
pane, so the hold that keeps a row from being delivered while it is rewritten
was invisible to the pane that drained it: a session event in a second split
pane resumed every stored outbox, saw no edit, and sent the original text.
The hold is now read across every pane subscribed to the outbox, which keeps
one source of truth — each pane's own scope-validated edit — and needs no
lifecycle of its own, so closing a pane or switching agent releases it by
simply no longer being an active edit.
The payloads an edit owns now travel with its token. The write that admits
the replacement retires the source row and tells every pane synchronously, so
by the time the send released the images the replacement had dropped, the row
holding them was already gone and nothing was released. That leaked the
payload and its object URL for the rest of the browser session.
* fix(ui): release attachments from cancelled queue edits
Recognized workspace paths in chat now read as their basename behind a
file-type glyph, so a file reference is identifiable before it is read.
Markdown, package manifests, TypeScript and other code, TSX/JSX components,
config/data, shell scripts, and images each get their own mark; anything else
falls back to a plain document. Paths sharing a basename keep the smallest
trailing suffix that tells them apart, and Windows paths keep their own
separator.
Classification wins over authoring syntax: a path written in backticks is a
file link first, so it drops the inline-code chip and renders exactly like a
bare path beside it. Code spans that are not file links keep the chip.
The full path stays addressable: it drives the file panel, the new tooltip,
and the message Copy action (which returns the original Markdown).
Author-written labels in [label](path) links are never rewritten, and text
that is not a recognizable path stays plain prose.
The glyph is painted as a masked ::before like the sibling GitHub mark, so it
stays out of the accessibility tree and out of copied text and follows the
link color in every theme. Extension classification moves to one shared
resolver that the file preview modal now uses too, replacing its own
code/text extension set.
Queue position becomes an explicit, operator-owned fact on ChatQueueItem
instead of an accident of arrival time and storage array order. One
comparator now serves the visible projection, the drain head selection,
the steer rebuild, and the alias merge, so what the composer shows is
what the Gateway receives.
Reordering lives entirely on one handle at the left of a queued row:
drag it, or focus it and press the arrow keys. Keyed rows keep focus on
the handle as it moves. Rows already handed to a run keep their place.
Also routes .chat-image-action through var(--cursor-action); its
hardcoded pointer was failing the cursor-policy test on main.
* feat(ui): mark GitHub links in Control UI chat
Chat markdown anchors pointing at github.com now carry a decorative GitHub mark before their text, so a bare URL and a [#3434] shorthand read as the same destination. The mark is painted from the bundled brand path through a CSS mask on currentColor: it stays out of the accessibility tree and out of copied text, tracks the chat text size, and follows the link color in every theme. Image-only links such as badges and code spans stay undecorated.
* feat(ui): break long autolinked URLs at any character in chat
An autolinked URL is one unbreakable word, so the inherited overflow-wrap only splits it after every other option is exhausted: a long URL was pushed whole onto the next line, and with the new GitHub mark ahead of it the mark was left stranded alone at the end of the previous line. Autolinked anchors now carry markdown-bare-url and get unconditional character break opportunities, so the URL fills the line it starts on. Authored link labels keep word-boundary wrapping.
* fix(ui): drop resting underline on chat GitHub links
* test(ui): split markdown link tests into a sibling file
markdown.test.ts crossed the 1000-line oxlint budget once the GitHub-link
cases landed. Move the link-rendering describes (autolinks, explicit
protocol links, file links, bare-url marks, GitHub marks) into
markdown-links.test.ts. Pure move; no assertion changes.
* test(ui): assert GitHub links rest without an underline
Covers the resting/hover decoration contract through the real rendered
page, and pins the rule's scope with a non-GitHub link that keeps the
base underline.
Give Control UI managed images bounded previews and shared full-image Open, Download, and Copy actions. Keep artifact access transcript-bound; the existing ticket is intentionally attachment-scoped to the lower-fidelity thumbnail.
Co-authored-by: Ittiz <github@daein.org>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* 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
* fix(gateway): close task terminals at run end
* refactor(agents): keep terminal task ownership internal
* test(gateway): type task-owned terminal fixtures
* test(infra): split agent run task ownership coverage