* fix(ui): reconcile every null tombstone and align session sort with the gateway
The sessions.changed merge deleted null-tombstoned fields from a hand-kept
eleven-field list while the gateway's tombstone policy
(session-event-payload.ts) had grown past it: toolOverrides, observerDigest,
controlOwnerSessionKey, restartRecoveryStatus, and goal leaked literal null
into rows typed optional-not-null, and every new tombstoned field would
silently repeat the drift. One loop over the event's null-valued keys now
owns the rule; updatedAt/activeLeafEntryId (the schema's only nullable row
fields) keep their explicit handling. Net −24 lines.
Riders in the same owner neighborhood:
- compareSessionRowsByUpdatedAt gains the gateway's ascending-key tie-break
(session-list-order.ts: "Stable key ties keep offset paging deterministic")
so tied rows — including all updatedAt:null rows — stop visibly swapping
when the canonical refresh replaces an event-driven reconcile.
- Deleted the unreachable kind === "cron" wire guards (gateway folds cron to
direct before projection, #115299; both sites needed an `as string` cast
because the protocol type has no such member).
- The stale-active-snapshot path returns the original result identity when no
defaults were passed, so the caller's result === state.result publish gate
skips a spurious sidebar re-render on every stale event.
* test(ui): pin the ascending-key tie for equal-updatedAt sidebar rows
* test(ui): tolerate CI renderer jitter in the typing-shift picker guard
The 2px y-tolerance flaked at 2.41px on CI renderers; the #122809
regression this guards against shifts by a full indicator row (~20px).
* fix(ui): stop per-message branch-list polling and unlatch transient branch failures
Four coupled defects around the chat branch dropdown, fixed at their owners:
- Every persisted session.message and every sessions.changed triggered an
unconditional sessions.branches.list RPC per viewing client; the gateway
branch cache watermark is max(seq), so each message append made the next
list a full transcript rescan. Branch topology only changes on structural
mutations, and the producer already records the reason — the reload is now
gated on rewind/branch-switch/fork/reset/new.
- A transient listBranches failure recorded success (chatBranchesSessionKey
set with branches=[]), permanently hiding the branch dropdown for that
session+connection with no visible outcome. The catch no longer latches;
the next history load retries naturally.
- sessions.branches.list errored for upstream-linked sessions even though
"no local branches" is their truthful steady state; the fresh-session
sibling was already converted to {branches: []} for the same reason
(spurious gateway-log failures). Only mutating siblings fail closed now.
- Branch state was keyed by raw session-key spelling and strict-compared in
the header and history gate, blanking the dropdown across the legacy
main -> agent:main:main alias window (same class as #124020); both
compares use areUiSessionKeysEquivalent.
Riders per pathfinder rule: deleted the write-only chatBranchesLoading flag,
a duplicate activeLeafEntryId recompute, and two provably-no-op second
dropPreSessionStartAnnouncePairs passes in chat-history-pages.ts (the filter
is single-pass complete and its inputs were already filtered).
Production LOC +27/-34 (net -7); regression tests fail pre-fix.
* refactor(ui): move branch-display ownership out of the header render
check-lint-core-4 flagged chat-pane-header.ts at 702 counted lines after the
equivalence gate landed there. The branches-for-display decision belongs to
the state owner anyway: displayedChatSessionBranches in chat-history.ts now
owns it and the header render consumes the fact.
check-lint-core-4 (run 31872054461) flagged no-shadow: the failure
diagnostics refactor in c59ef0b40c named its parameter 'error' while
two inner catches reused the name.
Root cause of the checks-ui-e2e boot-stall flake (run 31867505277): the
built index.html references modules relatively (./assets/*), and the real
Gateway resolves /assets/ at any path depth (src/gateway/control-ui.ts),
but the e2e preview server let Vite's SPA fallback answer
/chat/assets/index-*.js with index.html as text/html. The browser rejects
that module silently (console-only, no pageerror), so a document reloaded
on a deep link like /chat/research never boots. Under CPU load the
harness's replaceState("/")+reload pair raced the router's redirect and
reloaded on the deep link, producing the all-null 30s poll.
- Preview server now mirrors the Gateway's depth-insensitive /assets/
resolution; the racy replaceState canonicalization hack is deleted.
- Deterministic regression test boots a document directly on
/chat/research; mutation-proven (fails with the middleware disabled).
- Failure diagnostics generalized: boot waits capture screenshot, script/
resource evidence, pageErrors, and a navigation-traced event ring.
Proof: Testbox tbx_01m022feapn38ypws3mb2b9jhv saturated loops — pre-fix
2/10 failed with the exact CI signature; post-fix 0/14 at the same pace.
* fix(ui): keep the chat transcript anchored while the pane resizes
The width-change path wiped the virtualizer's entire item-size cache
(instance.measure()), collapsing every offscreen row to the 120px estimate
with no scroll compensation: the reader teleported mid-transcript and the
trickle of remeasure corrections made text visibly jump for several frames
on every pane or window resize.
Keep the stale offscreen sizes as estimates instead. The existing
synchronous resizeItem pass plus TanStack's per-row ResizeObserver re-seed
connected rows with fold-based scroll compensation, so the anchor row holds
still; offscreen rows correct lazily as they connect. End-pinned transcripts
keep following the tail via the existing height-change scrollToEnd path.
Regression e2e: mid-transcript anchor stays within 60px across
1280->1000->820->1280 resizes (pre-fix: anchor scrolled out of the viewport
entirely and scrollTop drifted by 250px+). Wipe introduced in #115059,
refined by #117687/#123713 without addressing anchoring.
* test(infra): deflake ephemeral-port rebind proof under runner contention
The bind->release->rebind cycle races foreign processes on shared CI
runners: a stolen port between release and rebind fails the assertion
(EADDRINUSE, seen on checks-node-compact-large-17). Retry the whole cycle
on a fresh ephemeral port, bounded at 5 attempts; a genuine release
regression still fails every attempt because the collision is with our
own lingering listener.
* test: prove port release on the allocated port; add end-pin resize proof
Address ClawSweeper review on #124013:
- ports-probe: replace the fresh-port retry with a connect probe against the
same allocated port. A lingering listener accepts the probe; a released
port refuses it. Unlike a rebind, the probe does not collide with foreign
outbound sockets that transiently occupy the port on busy runners, so the
release contract stays tied to one port with no retry at all.
- chat-resize-anchor e2e: add a width-only end-pinned case proving the
virtualizer's wasAtEnd compensation keeps the transcript within 2px of
the end across 1280->1000->820->1280 resizes.
* test(ui): widen resize-anchor drift bound for Linux font metrics
The fold-spanning anchor row re-wraps by a renderer-dependent amount
(42px macOS, 63px Linux CI at 820px). 120px keeps a wide margin below
the pre-fix failure mode (anchor out of viewport, 250px+ drift).
* test(tooling): register manager-session-update-race in memory helper routing
#124024 added the memory-core test on main without updating the
cross-lane routing expectation in test-projects.test.ts, breaking the
core-tooling shard on every merge ref that includes it.
Six sites strict-compared row.key === sessionKey while the sibling locked
check already used areUiSessionKeysEquivalent (#104045 upgraded only the
model path). In alias/transition windows (pre-hello legacy 'main' vs
canonical agent:main:main) the optimistic thinking/fast-mode row patch
found no row — the click produced no immediate visible change — and the
display resolvers missed the active row, falling back to agent defaults.
All lookups now go through the equivalence helper the rest of the flow
uses.
Broke the main gate on run 31865993941 (checks-ui-e2e 2/12):
expected 609.81 <= 609.21. The preceding scroll poll tolerates a <=1px
remainder because scrollHeight/clientHeight are integer-rounded, while
measureWithinAncestor compares fractional getBoundingClientRect values,
so the true bound is 2px, not 1.
The agents-page model catalog was cached per agent for the whole
connection: availability facts (provider keys added/removed, new models)
went stale with no refresh path except a full reconnect, while the chat
composer's sibling picker refreshes on open. Opening the overview picker
now re-reads through the same ensureModelCatalog owner with a refresh
bypass of the per-agent cache.
sessions.patch's 'model catalog unavailable' error told the operator
nothing actionable; it now states the catalog is still loading and to
retry — the failure text names the next step per doctrine.
state.onModelChanged has been () => undefined since the original Control
UI refactor (65e12328aa) and was never overridden; three call sites
awaited a decoy. Effective-tools previews key their requests on the model
override, so the post-switch refresh only needs a re-render, and the
model-switch reconcile already awaits the canonical session-list refresh.
Tests that used the seam as a deferral hook now hold the list refresh
open instead — the barrier the production code actually awaits.
* fix(ui): retire the local model override once the Gateway confirms the patch
settleModelOverride re-asserted the requested model into the shared
override map after every successful patch, and the reader prefers the map
over the server row unconditionally — so any external model change
(second operator window, channel /model, fallback rotation persisted by
the Gateway) never reached this window's picker or its effective-tools
key for the rest of the connection. The refreshed row already carries the
confirmed selection; retire the entry instead of shadowing the recorded
fact. deferListRefresh callers keep the published value (their refresh
has not run), rollbacks and newer-claim transfers are untouched.
* test(ui): split model-override lifecycle coverage into a focused file (max-lines)
* fix(gateway,ui): discard prepared attachment media on pre-persist exits and release catalog-open payloads
Two attachment-lifecycle leaks with the same shape: state whose owner
dropped the reference without reclaiming the resource.
- chat.send stages inbound media (~/.openclaw/media/inbound) during
attachment preparation; the abort and session-routing-changed exits
between preparation success and user-turn persistence returned without
deleting the staged files. Nothing references them afterward, and the
inbound sweep is disabled unless attachments.ttlHours is set, so default
installs orphaned them permanently. Both exits now discard through the
same deleteMediaBuffer path the error exits already use.
- Opening a terminal-catalog session cleared state.chatAttachments without
releasing payload-store entries, stranding Files and object URLs for the
tab lifetime; every sibling discard path releases first.
* test(gateway): drop duplicate unused inbound baseline block
* fix(ui): keep the GitHub mark on its label's line when chat wraps
The mark is an in-flow atomic inline, which carries a soft wrap opportunity
after it, so a chat line could end on the octocat and start the next one with
the reference it labels. Painting the mark out of flow and reserving its space
with the anchor's own padding removes that opportunity: nothing inside the
anchor precedes the label any more.
The anchor stays inline, so a long bare URL still fills the line it starts on
instead of moving down whole -- the sibling file-link chip's atomic treatment
could not be reused here.
* test(ui): route GitHub link layout test through Node
The agents-page fallback editor gated staging on a resolvable primary
model, but the gateway resolver honors { fallbacks } with no primary
(agent-scope.ts, including the explicit empty-array-disables-global
contract). With a fully implicit default model, typing a fallback chip
cleared the input and staged nothing — no chip, no error, no write.
Clearing the primary had the sibling bug: removeFormValue deleted the
whole model node including authored agent fallbacks.
One stageModelShape owner now writes the smallest representable shape
(bare string, { primary, fallbacks }, { fallbacks }, or removal), and
both entry points share existingModelParts instead of duplicating
existing-shape probing.
* fix(ui,gateway): make attachment admission one funnel and clamp advertised ceiling to the WS frame budget
Three residual gaps from the #123654 size-limit fix shared one root cause:
attachment admission policy was scattered instead of owned.
- hello-ok advertised the decoded config ceiling (20MB default, higher with
mediaMaxMb) without accounting for base64 4/3 expansion against the 25MiB
WS frame cap, so a 19.6-20MB attachment passed the client guard and the
encoded chat.send frame still hard-dropped the connection (1009) for
every pane. The policy owner now clamps the advertised maxBytes to what
one frame can carry.
- Large-text paste, data-URL image paste, and browser-annotation handoff
constructed attachments without any size check, bypassing the guard that
only lived inline in appendAttachmentFiles. All intake paths now share
one admission funnel (chat-attachment-admission.ts).
- Zero-byte files rendered a normal chip, then the payload assembler
silently dropped them on send; the funnel rejects them at intake with a
named toast.
* refactor(ui): drop unused exported type from attachment admission module
resumeStoredChatOutboxes runs on every session.message/sessions.changed
event; a never-attempted durable head parked behind an active run issued a
1000-message chat.history request per transcript event only to learn the
run was still active. The session row the wakeup event itself reconciled
already answers that question, so consult it first and keep the history
fetch for attempted rows whose delivered-detection genuinely needs it.
* test(ui): add sidebar alignment capture harness
* fix(ui): align sidebar rows to shared grid
* fix(ui): distinguish sidebar owner presence
* fix(ui): visually hide sidebar pages label
* fix(ui): collapse the hidden pages head row
* fix(ui): keep the floating pages action clickable
* fix(ui): scope facepile dedup to the rendered lead and reveal the pages editor on touch
ClawSweeper P1s on #123938: the facepile unconditionally excluded the
session creator even when the lead chip showed the archivist or nothing
at all, hiding a live viewer; renderSessionLeadingState now returns the
rendered owner identity as the single dedup source. The hover-revealed
pages editor gains the standard hoverless-pointer visibility override.
* fix(ui): re-attach chat virtualizer when a foreign host re-stamps the transcript
Switching a board session from Dashboard back to Split left the docked chat
pane blank (sizer-height empty space, no rows) until an unrelated state change
re-rendered the pane or the user scrolled.
Root cause: the transcript template is stamped by openclaw-chat-sidebar-region
(it receives the chat template as a property and renders it in its own, later
update cycle), but the TanStack virtualizer only re-resolves its scroll element
inside the pane host's update. After the face-switch re-stamp no pane update
follows, so the virtualizer stayed detached: scrollElement null, scrollRect
zeroed by the hide transition, calculateRange null, zero rows painted.
Fix, at the owner (ChatSessionVirtualizerHost): attachment now follows the DOM
identity the scroll-element ref records — an identity change queues a microtask
that re-runs the virtualizer adapter's update hook. Zero-size rects from hide
transitions (display:none sidebar panels, unmount teardown) are ignored so they
can no longer wipe measured row heights or become the virtualizer viewport.
Live-verified against a remote gateway where the broken state persisted 5+
seconds pre-fix and one attachment sync healed it. Regression tests fail
pre-fix; e2e covers the deferred-latency dashboard->split flow.
* chore(ui): raise startup JS baseline for transcript virtualizer re-attach fix
Measured 333338 B gzip on PR #123974 run 31861214469 (+345 B over the base
run 31860496921 at 332993 B; prior baseline 332040 B predates recent main
drift). Absolute 350 KiB maintainer cap unchanged. Growth is the foreign-host
attachment sync in chat-transcript-controller.ts — real fix code, not
speculative surface.
* feat(gateway): add remote-exec cloud placements
* feat(codex): run cloud turns through remote exec
* fix(sandbox): quote ssh_config path directives containing whitespace
Crabbox lease keys default to ~/Library/Application Support/... on macOS;
unquoted IdentityFile/UserKnownHostsFile/CertificateFile arguments tokenize
on the space and fail as 'extra arguments'. Found via live remote-exec
cloud-worker proof.
* test: consolidate gateway maintenance schedule coverage
* fix(ci): invalidate plugin sdk declarations on state changes
* 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>
* fix(ui): fork active sessions from stable history
* fix(protocol): align active fork types
* test(ui): match main-session fork routing
* fix(sessions): unify stable fork admission
* fix(native): fork active sessions from stable history
* fix(android): fork active sessions from stable history
* style(android): simplify active fork result handling
* fix(native): preserve legacy fork transport API
* test(native): complete legacy fork transport stub
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
chat links used --accent while user bubbles are filled from --accent-subtle (and peer bubbles from a per-sender hue), so links sat on their own hue at 3.95:1 worst case. New --link/--link-hover tokens derive from each palette's audited --accent-hover and clear WCAG AA on every bubble surface and sender hue; the hover opacity fade, which lowered contrast, is replaced by a color shift.
The header-to-transcript blend spanned the full pane width, so it painted over the strip where the transcript scrollbar lives and the thumb vanished under the fade while scrolling. Stop the blend one transcript gutter short of both pane edges and make that gutter a token shared with the transcript column, which keeps every pixel of content faded while the scrollbar stays visible (symmetric so RTL keeps the same clearance).