* fix(ui): show active model context budget
* fix(ui): simplify compact model label
* fix(ui): preserve provider when matching context budget
* fix(ui): hide stale context during model switches
---------
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Mark stale terminal tabs exited when the Gateway rejects input or resize, so operators see an actionable outcome instead of silently losing keystrokes.
* fix(gateway): drop internal error class names from operator output
* test(cli): drop stale Error: prefixes from capability expectations
* test(cli): align invalid-port output with canonical formatting
* fix(errors): preserve primary structured codes
* style(cli): format invalid-port expectation
* test(cli): align shared error rendering expectations
* fix(gateway): scope error code rendering to agent failures
* test(errors): redact opt-in structured codes
* refactor(errors): keep canonical formatter callback-safe
* test(audit): drop stale Error: prefixes after formatter cleanup
PR #124336 landed audit's gateway-error rendering while this branch was
in flight, so its two new assertions were written against the prefixed
output. The canonical formatter no longer emits the generic Error:
prefix, so the expected strings are updated to match.
* fix(gateway): report device worker capacity distinctly from disconnection
This separates paired, disconnected, and connected-but-saturated device worker failures so capacity exhaustion no longer looks like disconnection. Saturated hosts now tell operators to retry after a running turn completes, and table-driven coverage asserts all three messages. AI-assisted.
* test(gateway): expect unpaired reason in startup availability probe
* feat(ui): add session icon grid picker
* fix(ui): use radio semantics for icon grid
* fix(ui): use pressed-state action-grid semantics for the icon picker
ClawSweeper: menuitemradio/radio semantics promise arrow-key selection, but arrows here only move focus (selection persists server-side, so select-on-focus would patch per keypress). Plain buttons with aria-pressed match the click/Enter-only contract.
* 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>
#124100's disarm-at-ACK custody rests on dispatch persisting the
referencing transcript on every post-ACK path. The blocked-turn path breaks
that: a before_agent_run block persists only the redacted block reason —
no media markers, no media facts — so the offloaded inbound refs stay
unreferenced forever (the inbound sweep is off unless attachments.ttlHours
is set). Dispatch settle now discards the prepared refs when the recorder
ended blocked, applying the same custody rule as the pre-ACK owner.
Regression test drives a blocked dispatch with an offloaded attachment and
asserts the inbound dir returns to baseline; fails pre-fix.
Pin OCM v0.2.32 so Performance validation uses upstream native .mts discovery and transitive workspace dependency handling instead of the local Bash/tsx trampoline.
Four nodes-CLI repairs at their owners:
- nodes status --last-connected joined a second pairing-scoped RPC
(node.pair.list) to reconstruct last-connection times the gateway already
records on every node.list row (lastConnectedAtMs, max of stored pairing
history and live connection, since 2dcd47d4f4). The client-side join also
preferred stored history over a live connection instead of taking the max,
and made the command fail for callers whose auth grants operator.read but
not operator.pairing. The filter now reads the recorded fact;
connectedAtMs covers gateways predating it.
- nodes list --connected blanked pending rows and then printed "Pending: 0"
while requests waited — asserting a fact it never checked. Pending rows
carry no connection state to filter on; they now always show.
- nodes remove/rename carried unreachable !nodeId guards (resolveCliNodeId
throws "node required" on blank input and every parse layer drops id-less
rows) whose dead text pointed at the wrong command (nodes pending targets
pairing requests, not paired nodes). Guards deleted; the reachable empty
--name branch now hints at nodes list.
- status.node-mode derived node-service liveness from the formatted
runtimeShort display string — parsing a fact back out of its own
projection, dead in all first-party call paths (both callers pass the
runtime object it is derived from). Field and branch deleted.
e2e test updated to pin the single-RPC shape (asserts node.pair.list is NOT
called); node-mode fixtures now use the production shape.
* refactor(anthropic): split session-catalog into concept modules
Follow the #124178 sibling seam pattern while preserving behavior. Remove the max-lines suppression and baseline entry after the split.
* refactor(anthropic): own record caches in session-catalog-discovery
The record-shaped caches (catalogDiscoveryCache, claudeSessionScanCache,
their entry types, bounds, and TTLs) are consumed only by discovery; moving
them out of scan removes the scan->discovery type-import edge flagged by the
madge topology gate and leaves scan as record-agnostic fs primitives.
* fix(state): doctor --fix loops on "migration required" for older state databases
Databases at user_version < 3 cannot complete the STRICT table rebuild when
they predate a first-use additive column such as
device_bootstrap_tokens.setup_id. The rebuild validates each table against its
full canonical column set, so it fails with:
SQLite table device_bootstrap_tokens does not match its canonical columns
(missing setup_id)
The repair runs in a single transaction, so this rolls back the audit-events-v2
work that already succeeded. The next open re-detects the legacy audit ledger
and reports that migration instead, which names a table unrelated to the real
failure and leaves doctor --fix repeating the same message. It also early
returns out of runLegacyStateMigrations, skipping the config machine-state
migration, so the gateway keeps failing config validation.
STATE_PERSISTENT_SCHEMA_COMPATIBILITY.allowedMissingColumns already treats
these columns as legitimately absent, so the persistent schema contract and the
STRICT rebuild disagreed about the same database.
Ensure the first-use additive columns immediately before the STRICT rebuild in
both paths that run it, gated on previousVersion < the STRICT schema version.
This mirrors repairLegacyGatewayRestartHandoffsForStrictMigration directly
above it. It is not a semantic change: the rebuild recreates each table from
canonical SQL that already declares these columns, so the resulting shape is
identical either way, and scoping it to pre-STRICT databases keeps the
absent-until-first-use behaviour for every normal open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(state): cover the cold-open route and session_groups first-use columns
Adds independent regression cases for the two routes the shared helper covers
but the original fixture did not exercise directly:
- session_groups.cwd / .worktree, the other first-use additive columns, so the
coverage is not specific to device_bootstrap_tokens.setup_id
- the writable cold-open path, which upgrades the same database during gateway
startup without doctor running first
Both fail on an unpatched tree with the canonical column mismatch and pass with
the fix, alongside the existing doctor-repair case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(state): enforce that first-use additive columns stay bare nullable
Materializing these columns before the STRICT rebuild is only safe while every
one of them is bare and nullable: the rebuild adds them to tables that already
hold rows, so a NOT NULL, defaulted, constrained, or key-bearing column would
fail the migration or silently rewrite existing data.
That property was documented as a convention on the definition list. Assert it
against the canonical schema instead, one case per first-use column, so adding
a non-nullable column to the list fails CI rather than pre-STRICT upgrades.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Configured agents now use their canonical direct store paths while retired and manual owners retain broad discovery. This prevents Gateway startup prewarm from rescanning every agent root once per configured agent.
config.set and config.patch each hand-rolled the same normalize ->
raw-validate -> plugin-validate -> respond-invalid pipeline (including the
duplicated errorShape/details blocks the round-5 error-text fix touched in
both places). validateSubmittedConfigOrRespond now owns it; both handlers
consume { validationCandidate, config }.
Behavior-neutral: same validators in the same order, same error shapes
(pinned by the existing 26 config tests including the round-5 regressions).
Net -11 production LOC and one place to evolve validation error content.
* fix: keep claude-cli prompt-cache prefix stable across turns
Claude CLI has no cache_control breakpoint, so mashed per-turn system
prompts rewrote the native prefix and burned cache hits on follow-ups.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): scope Claude prompt cache suffix
* docs: scope Claude CLI cache guidance
* test(cli): cover Claude cache prompt modes
* fix: gate Claude CLI prompt cache flag
* docs: clarify Claude CLI cache flag gate
* fix: preserve Claude CLI system prompt roles
* fix(claude-cli): close cache flag compatibility gaps
* fix(claude-cli): reject prerelease cache flag versions
---------
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Every sessions.changed is reconciled twice — once by the capability's own
subscribeEvents handler and once by the chat page's reconcileSessionEvent —
and reconcileSessionHistory always built a fresh sessions array, so the
second, content-identical pass defeated every downstream
result === state.result publish gate and re-rendered the sidebar per event.
The merge now returns the original result identity when the merged row is
content-equal to the existing row (shallow compare with structural fallback;
rows are wire scalars/plain objects). The isOlderSessionSnapshot strict-<
guard could not catch this: the duplicate pass carries the same updatedAt.
Production +31 (the equality gate); regression test proves second-pass
identity and fails pre-fix. 9082 UI tests green.
The warmer replays the same test envelopes as the compact shard lanes
through scripts/ci-run-node-test-shard.mts, but without the lanes'
NODE_OPTIONS=--max-old-space-size=8192. Run 31874567859 lost a worker in
agents-embedded-agent-run under the default heap and the failure skipped
every cache-save post step, leaving the day's transform/compile caches
unrefreshed. Rerun 31918467031 passed, confirming load-dependence;
matching the lane environment removes the gap.
Main run 31914404948 (checks-ui-e2e 6/12) timed out waiting for
config.set: the test filled the Advanced settings Endpoint field ~300ms
after navigation and the captured diagnostics show the value reverted to
the loaded snapshot with no model commit. syncScalarInputIdentity resets
element.value whenever the snapshot identity changes, which also wipes a
focused field's DOM text the model has not committed yet (mid-keystroke
or mid-automation fill) when a boot-time config.get response lands.
Focused inputs with a live edit now keep their value through identity
refreshes (revalidate only); blurred fields keep the authoritative-reset
contract, pinned by the existing repeated-rows test. Regression test is
mutation-proven (fails 'local-api' != 'form-api' without the guard). The
e2e test now asserts the filled value stuck before awaiting config.set,
so a future regression fails loudly instead of a silent 30s timeout.
* feat(workers): run device sessions from Gateway bundles
Install the current Gateway bundle before a device environment becomes ready, verify it at attach and tunnel boundaries, launch only from the immutable namespaced bundle directory, and retire stale environments for idempotent reprovisioning. Remove the local execution mode and preserve the node-local build claim only as temporary inventory metadata for the final projection/cleanup slice.
* docs(runners): record Gateway bundle cutover
* test(ci): repair runner validation fixtures
# Conflicts:
# src/scripts/test-projects.test.ts
* fix(workers): surface outdated node recovery
Keep legacy runner inventory diagnostic-only while exposing the update-and-reconnect action through node, environment, provider, placement, and Control UI surfaces.
* fix(workers): reject legacy inventory with recovery
* fix(workers): bundle worker deploy closure
* test(workers): close bundle cutover gates
* fix(workers): compose browser runtime at build
* fix(workers): satisfy bundle cutover gates
* fix(workers): route temp runtime through infra
* docs(workers): align bundle host guidance
* fix(ui): fence outdated session destinations
* fix(ui): keep the GitHub hovercard open while the pointer reaches it
Hover intent now spans the link and the portaled card: leaving one for the other keeps the card open through a bounded traversal grace, and only leaving both dismisses it. The card also receives pointer events again, so its text can be selected. Escape, click, focus, viewport, and route-removal dismissals are unchanged, and the close timer is cleared on every close and disconnect.
* fix(ui): make the GitHub hovercard title a navigable link
The preview card's title now renders as an <a> reusing the exact
activation href, opening in a new tab like other external Control UI
links (buildExternalLinkRel/EXTERNAL_LINK_TARGET). Because the card is
portaled to document.body outside the provider's delegated focus
listeners, focus landing on the title link needed its own tracking,
mirroring the existing pointerenter/pointerleave pattern; that focus
hold releases once the pointer leaves the card after a pointer-initiated
open, so clicking the title link can never leave a hover-opened card
stuck open with the mouse gone.
* test(ui): prove hovercard pointer traversal and title-link click in real Chromium
Extends the existing github-link-hovercard e2e test with two cases: one
drives real page.mouse.move coordinates from the source link, across the
gap, onto the portaled card, and confirms it stays open and only closes
once the pointer leaves both surfaces; the other clicks the card's title
link and confirms the resulting popup navigates to the item URL.
* fix(ui): make the GitHub hovercard an accessible interactive popover
The card declared role="tooltip" while owning a focusable title link. ARIA
tooltips are descriptive text: their content is flattened and unreachable, so
the link existed for the pointer only. Drop the tooltip contract instead of the
link, and give the card the popover semantics its content already needs.
The card is now a non-modal role="dialog" named by every render state, and the
trigger carries aria-haspopup/aria-expanded/aria-controls in place of the old
aria-describedby juggling. Because the card is portaled to document.body it has
no tab-sequence neighbour, so Tab from the trigger forwards into it, Tab moves
between its links natively, and Tab past either edge or Escape returns focus to
the trigger with the card closed and no reopen.
The card's other references are links too: the repo reference and the title open
the item, the author opens their profile, and a pull request's diff-size chip
deep-links to the files-changed view. The title keeps the card's only underline;
the rest stay quiet until hovered or focused. Pointer behavior is unchanged -
leaving both the link and the card still dismisses it after the traversal grace
period, with no click required.
* fix(ui): disambiguate multi-account session labels
Parse the optional account segment on per-account-channel-peer DM and
group keys so Control UI no longer falls through to the raw routing
key. Append a non-default account as " · accountId" on label,
displayName, and fallback so two Alice rows from different Telegram
accounts stay distinguishable.
Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
* fix(ui): take the session account from the gateway row
The display resolver was reading the account out of the session key and
inferring it from label text. That missed the shipped `dm` spelling of
direct keys, invented an account-qualified group shape no key builder
emits, and treated any name ending in "(<account>)" as already
disambiguated.
The Gateway already projects the canonical `accountId` onto every session
row from the delivery route, so display consumes that and parses the key
only for panes rendered before their row arrives. Key parsing now covers
both `direct` and the pre-#11881 `dm` spelling, group keys go back to
their canonical account-less shape, and the account suffix is applied at
a single point instead of five.
`resolveChannelSessionInfo` classifies `dm` keys as channel sessions and
no longer reads a peer kind sitting in the channel slot as a channel.
* fix(ui): stop inferring session identity from rendered text
Three related text-inference defects in the same display neighborhood:
The account suffix was skipped when the resolved name already ended in
" · <account>". That reads rendered output as if it were a fact, and it
cannot tell an account apart from a user label that happens to look like
one. The suffix is now appended from the recorded account alone.
The string it was defending against came from the sidebar rename dialog,
which pre-filled with the resolved display name. Submitting persisted a
derived string as a real user label, which then outranked every later
derivation. Sidebar rows now carry the stored label alongside the
resolved one, and rename edits that, matching the Sessions page.
Channel classification accepted an account segment before any peer kind,
so `agent:<x>:<channel>:<account>:group:<id>` was filed under a channel
even though accounts qualify direct chats only and the canonical route
parser rejects the shape. The grammar now allows an account only before
`direct`/`dm`.
Resolver tests now prove the row projection is authoritative rather than
duplicating an account the key already carries: one case where only the
projection supplies it, and one where the two disagree.
* ci: retrigger after check-lint shard cancel
Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
* test(ui): prove rename opens on the stored label
The existing sidebar rename coverage uses a row that already has a stored
label, so it passes whether rename pre-fills the stored label or the
resolved display name. It could not catch the round-trip this branch
repaired.
Adds a browser case on a displayName-only row with a projected account:
the sidebar shows "Alice · cards" and the rename field opens blank. On
the parent commit the field opens holding "Alice · cards", which is the
string that used to become a real user label on submit.
* fix(ui): do not double-append a stored account suffix
A persisted label that already ends in " · accountId" must stay as-is
so "Alice · cards" does not become "Alice · cards · cards".
Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
* docs(ui): name the producer the account suffix check defends
The comment above withAccountDisambiguator claimed the rendered name is
never inspected, which the suffix check contradicts. State the real
contract instead: the account itself still comes only from the recorded
value, and the endsWith check is idempotence against the chat pane's
inline rename, which seeds its input with the rendered title and
persists a partial edit verbatim.
---------
Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
* fix(ui): abort stale destructive session confirmations on reconnect
SessionDataController retires the session-mutation scope on reconnect,
capability replacement, and host disconnect, but left any open
destructive confirmation dialog in place. If the operator confirmed
after reconnect the action silently no-opped, and the dialog's global
lock stayed held until manually dismissed.
Give SessionDataController an AbortController owned by the mutation
epoch, abort it whenever the epoch advances, and expose the signal on
SidebarSessionMutationScope. Wire it into every scope-bound confirm
dialog: batch delete, single delete, cloud-worker stop, preserved-
worktree removal, and session-group delete. confirm-dialog.ts already
tears down its modal and releases the lock on an aborted signal, so a
stale dialog now dismisses itself instead of surviving the reconnect.
Fixes#124227
* fix(ui): abort chat-header confirms too and surface a retry notice
The sidebar epoch-abort wiring left the chat-header delete/fork/archive
path uncovered: it builds its own SidebarSessionMutationScope from
connectionGeneration but never handed it a signal, so a header confirm
left open across a reconnect kept the shared confirmation lock forever.
Give ChatPaneBase its own AbortController, retire it everywhere
connectionGeneration advances (reconnect and pane teardown), and wire
the signal into the header's captured scope, matching
SessionDataController's controller for the sidebar.
Separately, resolving an aborted confirm to `false` looked identical to
an ordinary cancel, so the operator's lost intent vanished silently.
Reorder every destructive confirm site to check scope staleness ahead
of the `confirmed` guard and show the existing "Gateway connection
replaced ... Try again." toast on abort, matching the pattern
deleteSessionGroup already used for the same race. This also fixes a
regression the PR had introduced in a pre-existing main test
(app-sidebar.test.ts's "keeps a reconnected group delete retryable
after its confirm opened"), which is what failed
checks-node-compact-large-22 and checks-ui on the previous head.
The sessions sidebar held its sort mode in component state only, so a
reload silently reverted the user's Created/Last updated/People choice
back to Created while every sibling sidebar preference persisted.
Store the mode under openclaw:sidebar:sessions:sort-mode alongside the
existing sidebar preference keys and read it back at init. Resolution of
a gateway-denied People sort now lives in one helper shared by the render
path and the setter, keeping the element net-neutral in lines.
* fix(ui): reject blank required strings in Control UI update readers
The Control UI update readers accepted `""` for fields the canonical
UpdateAvailableSchema/UpdateScheduleStateSchema declare as NonEmptyString,
so a malformed Gateway payload rendered an update banner with blank
version text.
Root cause is the reader treating "is a string" as the contract for
fields whose canonical contract is "is a non-empty string". Fixed at that
boundary with the protocol's own dependency-free `isNonEmptyProtocolString`
primitive: required NonEmptyString fields now reject the payload when
blank, and optional ones are dropped rather than discarding the whole
payload.
Deliberately does NOT re-derive the canonical schema in the browser:
- `additionalProperties: false` is a producer-side contract the Gateway
enforces on its own outbound results (src/gateway/server-methods/update.ts).
Rejecting unknown keys client-side would turn every additive protocol
field into a blank overlay, since a service-worker-cached document keeps
an older bundle across a Gateway upgrade.
- Canonical `maxLength` counts grapheme clusters (typebox 1.3.6
guard/string.mjs), so the previous `subject.length <= 120` copy dropped
valid emoji subjects the schema accepts. The bound is producer-side and
is gone rather than approximated.
- typebox itself stays out of this module: it is in the Control UI startup
graph, which has a hard gzip budget (scripts/check-control-ui-performance.mts).
Drift is now pinned in tests instead of duplicated in production: the
boundary suite runs fixtures through both the reader and Value.Check over
the canonical schemas, asserting that anything the schema accepts still
reaches the overlay.
Production LOC: net -7 against main.
* fix(ui): restore the five-entry cap on tolerant commit filtering
readUpdateAvailableValue's per-entry filter/map dropped the existing
five-commit bound while switching from all-or-nothing rejection to
tolerant filtering. The canonical schema caps commits at 5
(packages/gateway-protocol/src/schema/config.ts), and the Updates
page renders every entry this reader returns, so the cap is the
protocol's own render-side contract, not extra strictness. Slice to
MAX_COMMITS after filtering and cover an eight-entry valid payload.
* fix(ui): make file-link label disambiguation near-linear
shortestFileLabels rescanned every path list at each suffix depth to
find the shortest unambiguous label, which is quadratic in path
count. A Markdown message with thousands of distinct file references
could stall the chat renderer. Replace the repeated scan with one
reversed-segment suffix trie built per render, so each path's label
depth is found with one child lookup per depth instead of a
comparison against every other path. Output is unchanged.
Fixes#124230
* test(ui): assert file-label lookup cost stays linear
Add a deterministic performance-contract regression for
shortestFileLabels: count Map#get calls via vi.spyOn while the trie
runs at two path-count scales and assert the growth stays linear
(generous band) rather than the pre-fix O(n^2) full-list rescan's
quadratic signature. No wall-clock timing, so it can't flake under CI
load; verified locally that it fails deterministically against the
pre-fix implementation and passes clean against the fix.
* fix(ui): drive session sharing icon from identity type
Remove the ID-string channel heuristic in the chat sharing menu.
Sharing identities are typed human/agent/system by the Gateway
protocol; there is no channel identity kind, so a human profile
whose ID happened to contain "channel:" was misrendered with the
channel glyph instead of the owner chip. Presentation now branches
on identity.type only.
* test(ui): describe the sharing fixture ID as opaque, not canonical
ClawSweeper flagged the comment on the channel-shaped human ID case:
it implied channel-shaped IDs are canonical for human profiles, but
durable profile IDs are generated UUIDs. The real supported path is
an opaque human identity ID (e.g. an inbound SenderId) that can
happen to contain "channel:" as a substring.
* fix(ui): use native placement for the lobster dismiss menu
The dismiss menu pre-clamped the pointer anchor against a hard-coded
264x80 guess before opening a bottom-start dropdown. Web Awesome's
dropdown enables `flip` without ever setting `flip-fallback-placements`,
so flip can never move the popup off its preferred placement; against
a bottom-start anchor pinned to the footer's bottom edge, that left
`auto-size` as the only thing that could react, shrinking the menu and
scrolling its two items whenever content grew past the guessed height.
Since the pet always sits on the footer ledge, anchor the popup with
`top-start` instead, the placement that already has room, and pass the
raw pointer position straight through. `shift` and `auto-size`, already
enabled on every wa-dropdown, then size the popup from its real content
instead of a guess that breaks under longer labels or larger type scale.
* fix(ui): correct the type-scale test and floating-ui rationale
The "enlarged type scale" e2e coverage overrode --wa-font-size-smaller/
--wa-line-height-condensed, but the dismiss menu's items render through
the app's own .session-menu__item class (layout.css), which sets an
explicit font-size from --control-ui-text-scale and wins over
wa-dropdown-item's shadow styles - the override was a no-op, which is
why the added test failed in CI. Switch it to set --control-ui-text-scale
the way bootstrap.ts does at runtime, and assert the label's computed
font-size and its own unclamped height both grow versus a baseline (the
row's 28px min-height floor otherwise hides a no-op).
Also correct the PR's floating-ui rationale: @floating-ui/core's flip
middleware does not treat an empty flip-fallback-placements as "no
fallback" - "" is JS-falsy, so it computes a real candidate list via
getExpandedPlacements. The actual reason a cramped placement can still
get shrunk instead of relocated is that wa-dropdown's auto-size (size)
middleware runs after flip and clamps whatever placement flip lands on.
* fix(ui): condense lobster dismiss-menu placement comment
ClawSweeper P3: the 12-line version-pinned comment explaining top-start
placement was the sole reason production stayed net +2. Trim it to the
repo's inline-comment doctrine (1-3 lines, why/contract/bad-outcome, no
version-pinned essay).