* perf(ui): consolidate Control UI boot chunk graph for HTTP/1.1 gateways
The Control UI boot flow (app shell + sidebar + chat route) lazily loaded
~124 automatic chunks in one burst after the gateway handshake, which the
gateway's HTTP/1.1 transport serializes into ~24 six-connection round-trips
on high-latency links (Tailscale, remote gateways).
Add a measured boot-module manifest (ui/config/control-ui-boot-modules.json,
regenerated via pnpm ui:boot-manifest:gen) and a control-ui-boot codeSplitting
group that merges exactly that module set into a handful of chunks with
recursive dependency inclusion. Lazy islands (locales, ghostty-web, novnc,
non-default routes) keep their own chunks; stale manifest entries degrade
gracefully back to automatic chunking.
Measured on the built dist with the mocked gateway (chat route, 3 runs):
unique boot JS requests 140 -> 45, raw boot JS 3751 -> 3717 KiB, chat
composer interactive at simulated 50 ms RTT ~1600 ms -> ~575 ms.
Largest-CSS budget rises 45 -> 47 KiB for the merged boot CSS; startup JS
gzip baseline ratchets down (345049 -> 339214 B) as consolidation shrinks
the startup graph.
* chore(ui): refresh boot module manifest after rebase onto current main
* fix(ui): stop the pending lazy shell action replay loop starving boot
When a pending lazy shell action (command palette open, panel toggle)
replayed while the shell was still splash-gated, the dispatched event had
no rendered element to consume it and re-entered requestLazyElement in a
microtask cycle: request -> load -> replay -> dispatch -> request. The
cycle starved tasks (Gateway WebSocket messages included), so the boot
never finished and the recovery e2e froze on the splash screen.
Gate replay on the element actually being rendered: the controller skips
the action after load until the host's render root contains the tag, and
restorePendingLazyAction skips dispatch while a defined element is still
render-gated. The host retries after every completed update, so the replay
fires on the update that first renders the element. Regression test fails
on the pre-fix controller.
* fix(ui): re-anchor the scope-upgrade details popover before opening
wa-popover resolves its `for` target once per property change and never
re-resolves a missing or replaced anchor. The trigger with the shared id
can render after the popover's first update (the header trigger ships with
the lazy chat chunk), leaving the opened popover permanently invisible:
active popup with a native [popover] part stuck at UA display:none because
showPopover() never ran without an anchor. Re-arm the watcher when opening
while the anchor is missing or disconnected.
* test(ui): compare settled layouts in device-scope stability assertions
The 0.5px no-move assertions sampled geometry that later reflowed when the
details surface's first render fetched glyph subsets, reporting sub-pixel
drift the open never caused. Burn in the one-time open per context and
sample the baseline adjacent to the click.
* fix(ui): map the keyboard shortcuts dialog in lazy replay gating
Current main added the keyboard-shortcuts lazy shell event; the replay
gate's exhaustive event-to-element record needs its entry.
* chore(ui): refresh startup budget baseline after rebase onto current main
* feat(control-ui): add Cmd+/ keyboard shortcuts overview dialog
A declarative shortcut catalog (keyboard-shortcut-contract/catalog) now owns
every Control UI chord: matching, platform-aware formatting, and the new
lazy-loaded Cmd+/ (Ctrl+/ elsewhere) overview dialog all derive from it, so
the overlay cannot drift from real handler behavior. Migrates the scattered
hardcoded shortcut labels (palette, debug overlay, appearance, sidebar,
terminal, workspace) and the duplicated Apple-platform check onto the catalog,
which also makes the previously meta-only sidebar and workspace chords
reachable on non-Apple platforms. The Slash matcher keeps Latin layouts that
put another printable on the physical Slash key (German Cmd+'-' zoom) intact.
* feat(control-ui): list non-obvious shortcuts in the Cmd+/ overview
Adds the invisible-but-real shortcuts to the catalog: composer history
recall (up/down), Esc dictation cancel, new-session-page submit (follows
the same chatSendShortcut preference as chat send), image-viewer zoom
(+/-/0), and the sidebar multi-select mouse chords (mod+Click,
Shift+Click) via a display-only Click key token. Multi-combo rows now
group their chips on the right instead of spreading across the row.
* fix(control-ui): keep Ctrl+Shift+Backquote toggling the terminal
The catalog matcher's exact-Shift comparison rejected the shipped
Ctrl+Shift+` terminal chord that main's predicate accepted (Shift was
deliberately unconstrained for layouts where the Backquote key is
shifted). Backquote now joins '/' as a Shift-insensitive key, with a
regression test. Found by ClawSweeper review on #128563.
* 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
* improve(ui): make Control UI feel native on mobile
* fix(ui): keep coarse-pointer input floor text-scale aware
* fix(ui): let self-sized controls opt out of the touch input floor
* fix(ui): fold per-control coarse-pointer font floors into the shared touch floor
* feat(ui): surface queued-outbox counts in composer hint, session rows, and offline footer
Follow-up to the offline-state unification: queued sends were mechanically
safe (durable outbox with reconnect replay) but only visible inside the
open session's thread.
- Composer offline hint now includes the visible session's queued count.
- Session rows show a clock badge with the per-scope outbox count,
independent of connection state (covers waiting-idle and failed too),
with alias-safe scope resolution so agent-main never double-counts.
- The offline footer button appends the aggregate ("· N queued"); the
connected state stays completely silent.
- One narrow subscription seam (subscribeStoredChatOutboxChanges) added
in composer-persistence; no send/drain logic touched.
* perf(ui): keep the startup bundle under budget with a lean outbox read module
The queued-count feature statically imported composer-persistence from
startup modules, hoisting the chat page's persistence machinery into the
startup chunk and breaking the Control UI startup JS gzip budget
(319.6 KiB > 314.0 KiB limit).
Split ownership instead of gaming the budget: a lean read/subscribe
module (ui/src/lib/chat/outbox-store.ts + codec/draft-state) serves
startup consumers (app-host, sidebar), while writes, migrations, and
drain stay in the lazy chat chunk (composer-outbox-store/composer-storage);
composer-persistence keeps its export surface for chat callers. Startup
is back to 313.8 KiB gzip at 12 requests with no chunking-config changes.
Also fixes an autoreview finding in the new summary: legacy bare-main
outbox rows now resolve through session defaults (online) or the
persisted mainAlias (offline reload) instead of trusting the row's stale
embedded agent id, so badge counts key to the same scope the sidebar
resolves. The shared footer status renderer is deduplicated into
session-row-badges.
* fix(ui): correct type-only import and const tuple in outbox split
* perf(ui): idle-load the outbox summary so startup carries no outbox code
The lean outbox read module still cost ~2.8 KiB of startup gzip against
1.5 KiB of budget headroom. Follow the sidebar chrome pattern
(lobster-pet/facepile): app-host idle-loads outbox-store, subscribes on
arrival, and passes the sidebar a resolver callback instead of letting
startup modules import scope resolution. Badges and counts hydrate
moments after load; before that the summary is empty by design.
Failed chunk loads recover on browser online events and, because chunks
are usually served by the gateway itself, on gateway reconnect — the
exact moment the offline badges become relevant again.
Raise the initial-graph packing ceiling 448->512 KiB: the grown core
graph split at the old boundary into an extra chunk, costing ~1.9 KiB of
startup gzip to compression-context resets (same documented tradeoff as
the earlier 400->448 bump). Startup lands at 313.5 KiB gzip / 9 requests,
matching the origin/main baseline, limit 315.0.
* perf(ui): cut forced reflows in chat render path
Profiled the Control UI with Chrome DevTools tracing against a real-data
gateway: session-switch INP was 367ms with ForcedReflow insights on every
load and interaction trace.
- chat-thread: per-row stable Lit ref callbacks (keyed by row key) so the
virtualizer stops cache-sweeping and re-measuring every visible row on
every render; prune callbacks when rows leave the list
- chat-composer: stable textarea ref on per-pane state instead of an
inline arrow, so the textarea is re-measured only on attach or when the
draft changes programmatically, not on every chat render
- app-sidebar: coalesce scrollHeight/scrollTop reads from updated() into
one rAF per frame instead of a forced layout flush per render
After: INP 133-143ms on the same interaction sequence, no ForcedReflow
insight in load or interaction traces.
* perf(ui): keep Control UI startup under budget after reflow fixes
The reflow fixes shifted rolldown's chunk partition: the 400 KiB core
maxSize boundary split one core chunk in two (~1.4 KiB gzip compression
loss) and re-balancing minted a tiny build-info startup chunk, pushing
startup JS to 370.8 KiB over the 370 KiB budget.
- pin build-info.ts + build-info-normalizers.ts into control-ui-shared so
partition noise stops minting extra startup preload requests
- raise core maxSize 400 -> 448 KiB so the core graph packs into fewer,
better-compressing chunks
Startup JS: 22 requests, 369.1 KiB gzip (main: 23 requests, 369.3 KiB).