The cron model picker help and the automations docs now say what
operators otherwise learn by surprise on the bill: summaries, triage,
and classification jobs run well on a lighter model, and --fallbacks
covers escalation. Copy-only change; pnpm ui:i18n:verify green, no
baseline drift.
Keep Gateway admission sidecar-gated while surfacing retryable startup progress in TUI and Control UI.
Unify deferred startup ownership and shutdown draining so late workers, plugin services, and sidecars cannot outlive transport teardown.
Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(ui): reveal clipped session history
Show a persistent earlier-history action for paginated sessions and reveal loaded content without chaining observer fetches.
Related: #110771
* refactor(ui): split chat history support
The sidebar queued-message badge and the composer queue reused the clock
glyph that also marks attached automations, so a session with both showed
two identical clocks distinguishable only by tooltip. Add an outbox tray
icon (Lucide inbox with the roof swapped for a rising arrow) and use it
for queued-to-send surfaces; the clock stays automation/cron-only. The
mock dev fixture now flags one session with hasAutomation so the badge
renders in mocked-dashboard proofs.
* refactor(webui): centralize chat metadata caching
* test(webui): cover shared chat metadata store
* refactor(webui): move ChatMetadataResult type to its store owner
* fix(webui): let the metadata store own revalidation lifetime
* fix(webui): satisfy no-promise-executor-return in retry delay
* refactor(ui): align approval page with redesigned approval card
Follow up #124767 by carrying severity accents and identity chips onto the standalone approval page.
Remove the unused execApproval.labels severity, plugin, and agent keys.
* refactor(ui): keep approval-page helpers in repo idiom
Reverts drive-by rewrites from the build pass: restore the exhaustive
decisionLabel switch (the ternary lost the never check), the early-return
appliedDecisionMatches, explicit gatewayCode comparisons, and align the
severity mapping shape with the sibling exec-approval-card.ts.
* test(ui): type approval severity fixtures
* test(scripts): wait for a parseable descendant pid in Bun smoke cleanup
The readiness gate only required the descendant pid file to exist, but
existsSync can observe it between open() and write(); a loaded runner
then reads an empty file and the integer assertion flakes (seen on PR
#124816 CI run 31971191374). Readiness now requires parseable content.
* fix(ui): drop the transcript keyboard-focus ring
The .chat-thread:focus-visible 2px inset outline (added in #120693) boxed
the entire main content whenever the transcript took keyboard focus
(Shift+Tab, closing transcript search) and read as a broken border that
stopped above the composer. The transcript stays keyboard-focusable for
scroll keys; it just paints no focus box. Accepted tradeoff: no visible
focus indicator on the scroll container.
* feat(ui): single primary composer button with Enter-steer and Esc-stop
The composer now renders exactly one primary action, always rightmost:
- draft or attachments present: one send-shaped button whose label follows
the session follow-up mode (Send / Queue / Steer); the stop button no
longer renders beside it
- empty while a run is active: Stop (tooltip "Stop (Esc)"); Escape in the
composer aborts the run, deferring to reply-clear and menu precedence
- empty while idle: disabled send labeled "Write a message to send."
Enter on an empty composer while a run is active steers the oldest
steerable queued message — Enter queues, Enter again steers — using the
same eligibility predicate as the queue chip's Steer action (shared
steerableQueuedMessage helper). The mic/dictation control is now always a
compact secondary control left of the primary instead of morphing into
the primary when the composer is empty. Dead ChatRunControlsProps fields
(hasMessages, onExport, onNewSession, showPrimary, showSecondary) and the
composer exportMarkdown wrapper are removed; the assertion-safety
baseline entry for chat-composer.ts shrinks 8 -> 7.
* refactor(ui): quiet the idle composer mic
The idle mic was an accent-tinted two-segment pill (mic + permanently
visible device-picker chevron) that outweighed the primary action. It now
matches the attach button's ghost weight: transparent idle, subtle hover,
and the accent treatment is reserved for the recording and talk-active
states so the idle -> recording escalation reads stronger. The device
picker chevron collapses at idle and reveals on hover/focus-within (kept
always visible on coarse pointers, where dictation matters most; reveal
transition disabled under prefers-reduced-motion). The idle tooltip now
teaches the gesture split — "Tap to talk · Hold to dictate" — while the
aria-label stays "Start voice input".
* refactor(ui): split composer keydown and oversized composer test files
chat-composer.ts crossed the 700-line oxlint budget after the keyboard
additions; the keydown handling (menu routing, history keys, Escape-stop,
Enter steer/send) moves to chat-composer-keydown.ts behind a typed deps
factory. The composer unit tests split their primary-action/keyboard
coverage into chat-composer-actions.test.ts over a shared
chat-composer.test-support.ts, and the catalog/model-discovery e2e cases
move to chat-composer-catalog.e2e.test.ts. Pure moves, no behavior
change; no max-lines suppressions or baseline entries added.
* chore(ui): keep composer test-support ComposerProps internal
The split exported a type nothing imports; the dead-export scan is a
merge gate.
* fix(ui): narrow composer keydown target and adapt keyboard tab-order proof
The keydown split moved two bare textarea assertions into a zero-baseline
file; a single instanceof narrowing at the handler entry replaces both
(the handler only binds to the composer textarea). chat-composer.ts's own
assertion baseline ratchets 7 -> 5. The pointer-activation e2e now walks
the real post-redesign tab order (mic, focus-revealed picker trigger,
then primary send) instead of assuming one Tab.
* fix(ui): gate Enter-steer on the queue chip's visible availability
ClawSweeper P1: the Enter path only required an abortable run, so
offline Enter swallowed the keypress and invoked a steer lifecycle that
returns at !connected with no visible outcome, while the queue chip's
Steer button was correctly hidden behind connected && canCompose. The
keydown path now mirrors the chip's exact boundary (connected, canSend,
abortable, visible queue surface including sessionKey-less pending
rows).
* feat(ui): surface approvals passively and redesign the approval card
Approvals no longer auto-open the centered modal: the queue is reachable
only through the sidebar attention chip, while the owning session shows
the inline card and other sessions surface via the session-row shield
icon, agent badges, and the chip. Deletes the inline-vs-modal exclusion
machinery (modalApprovalQueue, inlineApprovalId, forceShowAll) and makes
modal dismissal close the view instead of denying the active request.
Card: severity now drives the accent color instead of a table row,
plugin/agent render as header chips, the session key moves behind a
collapsed Details disclosure (modal only), and low-value exec rows
(Resolved/Security/Ask) collapse into the same disclosure. The codex
app-server bridge stops duplicating the session key into description
text; the envelope already carries it.
* fix(ui): gate settings Escape on the approval dialog's recorded open state
ClawSweeper caught that shouldIgnoreSettingsEscape still inferred an open
approval dialog from queue non-emptiness; with passive approvals a pending
queue no longer implies a visible dialog, so settings would swallow Escape.
The exec-approval element now records dialogOpen as a fact and the guard
reads it.
* 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.
The palette's session search swallowed every gateway error and rendered
the plain "No results" empty state — a failed search (e.g. sessions.list
rejecting with a store-needs-doctor migration error) was
indistinguishable from a successful search with zero matches. Silent
failure on a default path is the worst bug class in this repo.
Track a sessionSearchFailed flag (set only for the current request id
while the palette is open, cleared on every new query/clear), and render
"Chat search failed — check the gateway logs and retry" in the empty
state instead. Navigation commands stay usable throughout.
* 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.
* fix(ui): surface the blocking reason when Enter cannot start a new session
On the New Session page, pressing Enter while any submit gate was active
silently did nothing: handleComposerKeydown returned when canSubmit was
false, and submitDisabledReason() only knew 3 of the ~14 gates canSubmit()
checked, so during transient states (async worktree/model preference
restore, WS reconnect, roster hydration) neither the tooltip nor the page
explained why nothing happened. Clicking Start seconds later worked.
Fix at the owner: replace the divergent canSubmit()/submitDisabledReason()
pair with one ordered gate table (submit-gates.ts). Every block is a typed
{gate, reason} entry; only the closed silent list (busy button, empty
draft) may omit a reason, enforced at the type level so a new gate cannot
silently eat Enter again. canSubmit, the Start tooltip, and the terminal
split-button all derive from the same walk.
Enter during a reasoned gate is now recorded as a blocked submit attempt:
the flow captures the gate, the composer renders the reason as a role=status
notice, and the notice retires itself as soon as that gate lifts (no queue,
no timers). Gates that already render a page callout (outcome-unknown,
invalid worktree name) are excluded to avoid duplicate text.
Regression tests: a gate-table completeness sweep asserting every blocking
scenario yields canSubmit=false plus a visible reason (fails if a gate is
added without one), Enter-while-restoring surfaces then clears the notice,
and composer-level coverage that reasoned gates consume Enter while silent
gates keep it native.
* test(ui): scope outcome-unknown e2e assertion to the page callout
The consolidated gate table now also surfaces the outcome-unknown reason
in the Start tooltip, so the bare getByText match resolved to 3 elements
under Playwright strict mode. Scope the wait to the role=alert callout.