* feat(ui): accept drag-and-drop attachments in new-session composer
The new-thread composer only accepted attachments via paste and the +
menu; dragging a file onto it was silently ignored. Wire the shared
chat attachment drop handling onto the composer shell with the same
balanced drag affordance as the chat pane, and gate both composers'
drop/dragover cancellation on file drags so text/URL drops keep the
textarea's native behavior.
* fix(ui): cancel non-file drops outside editable composer targets
A URL dropped on the transcript, header, or composer chrome would hit
the browser default and navigate the app away, discarding drafts. Keep
native text/URL drops only when the drop target is an editable control;
cancel them everywhere else in both composers.
* fix(ui): treat disabled and readonly inputs as non-editable drop targets
A URL dropped precisely on a disabled composer textarea would still hit
the browser default and could navigate away. Check actual editability
(disabled/readOnly/isContentEditable) instead of selector shape.
* fix(ui): limit native drops to text-entry inputs
Enabled non-text inputs (checkbox, range) counted as editable drop
targets, so a URL dropped on one skipped cancellation and could
navigate the browser away. Restrict the native-drop exception to
text-capable input types, textareas, and contenteditable content.
* feat(ui): show waiting approval run status
* fix(ui): hydrate waiting approval status
* fix(ui): correlate approval status by run
* fix(ui): harden approval status reconciliation
The local exec runtime sanitized each stdout/stderr chunk with the
stateless `sanitizeBinaryOutput`, so an escape sequence straddling a read
boundary was escaped into visible text instead of being consumed. The
remote bash path already uses a per-stream parser via
`createStreamingBinaryOutputSanitizer` (#103706); this applies the same
treatment to the local path, with separate parsers for stdout and stderr
so neither stream can consume the other's pending sequence.
* fix(ui): keep steered composer messages visible until the transcript owns them
Typing while the agent was busy showed a brief steering state and then the
message vanished until the next full history reload. The steer path removed
the queued row at ack time and only restored the steered chip when the tab
had adopted the active run id, which never happens for runs started by
automations, other clients, or tabs opened mid-run.
- steer sends reuse the durable row's sendRunId as the wire idempotencyKey so
delivered-turn and history-proof reconciliation can correlate steered rows
- the steered chip is restored after every accepted ack, keyed to the active
run when the tab still tracks it, else to the steer's own gateway lifecycle
- terminal events materialize acknowledged steered chips into the transcript
before clearing them, and stale history reloads keep idempotency-marked
local turns until authoritative history catches up
- in-flight (unacknowledged) steers are never materialized, so a rejected
send cannot leave a phantom user turn behind
Fix authored with Codex; reviewed and hardened via structured review.
* fix(ui): use bracket access for __openclaw test marker (no-underscore-dangle)
* fix(ui): resolve steered attachment payloads through the store before materializing
Queue rows carry attachment metadata only; composer upload bytes live in the
payload store. Resolving through getChatAttachmentDataUrl keeps attachment-only
steers visible when their chip retires (Codex connector review finding).
* fix(ui): only user-role entries satisfy queued-turn presence checks
An assistant entry can carry the same run's idempotency key; matching it let
the chip re-add and terminal materialization skip the user's copy
(ClawSweeper rank-up move).
* fix(ui): show attributed sender identity properly in chat
Three defects around multi-user sender attribution:
- Sender labels rendered as 'name (<profile uuid>)': resolveSenderLabel
appended the id part even when it is an opaque UUID. Suppress UUID
suffixes server-side and strip already-baked ones in the UI normalizer
so historical transcripts read clean too.
- Attributed messages showed initials instead of the author's avatar:
chat sender metadata carries only the profile id, so resolveAvatar now
derives the canonical /api/users/<id>/avatar route (upload -> gateway
Gravatar proxy -> initials) from UUID-shaped sender ids.
- Author identity was hover-only: identity-resolving gateways keep the
always-visible avatar gutter even in 1:1-shaped sessions, and the
gutter renders the message author's avatar (with identity-colored
initials fallback) instead of the local viewer's.
* fix(ui): preserve legacy author identity and 404-safe sender avatars
Review findings: stripping the baked UUID suffix discarded the only
author key on legacy rows (the gutter then showed the local viewer's
face under another person's label) — the UUID now survives as
structured non-display sender identity. And the derived avatar route
can 404 (no upload, no Gravatar), so the gutter image swaps to
identity-colored initials on error instead of a broken image.
* fix(ui): attribute bare-UUID legacy sender labels to their profile
A nameless legacy sender labels as the bare profile UUID; recognize that
whole-label shape as the sender id (keeping it as last-resort display)
so the row resolves the author's avatar instead of misattributing to the
local viewer.
* perf(state): cap the per-agent SQLite handle cache with LRU eviction
Multi-tenant hosts open one WAL database per agent (~3 file descriptors
each); the process-local handle cache was unbounded, so large fleets
exhausted descriptors. Cache hits now refresh LRU recency, and cache-miss
opens evict the oldest non-transactional handle before constructing the
new one, capped at 64 open handles. Eviction closes the process-local
handle only; registry rows and durable data are untouched and evicted
databases reopen transparently on next access.
* fix(state): fleet-wide introspection reads stop opening writable agent databases
Live testing an 81-agent gateway surfaced three full-fleet sweeps that
opened every agent's SQLite database writable (schema ensure + registry
write transaction per open): the 60s health snapshot's per-agent session
summaries, usage-cost cache reads behind the gateway usage endpoints, and
the zalouser doctor detector that ran from every CLI startup. Each sweep
churned the new bounded handle cache and saturated the event loop.
Session listing gains a readonly non-registering variant (SDK exposes it
as an additive readOnly flag on listSessionEntries); health and usage
reads use it, with transient-lock tolerance owned by the health caller.
The zalouser detector gates on the channel/credentials actually existing,
collects once per pass, and detection reads go readonly while migration
apply stays writable. The usage refresh queue's flat 50ms busy retry
becomes exponential backoff capped at 5s, and refreshing summaries retain
their cache timestamp so the 30s TTL gates fleet rescans.
* perf(state): make per-agent handle eviction and reopen cheap
Eviction only works if reopen is cheap. Reopens after eviction previously
repeated full first-open work; ensureOpenClawAgentSchema takes BEGIN
IMMEDIATE and the registry upsert writes the shared state DB, so reopens
blocked in synchronous busy waits (observed 14-22s event-loop stalls with
reconcile workers holding write transactions). Owner/schema validation and
registration now run once per path per process with invalidation on
quarantine and disposal; the read-only integrity guard still runs on every
physical open. Eviction closes with a PASSIVE WAL checkpoint instead of
TRUNCATE, which waits on readers; orderly dispose keeps TRUNCATE so
sidecars are flushed before unlink.
* ci: regenerate plugin-SDK API baseline, drop prod test-seam exports, mock readonly accessor
The additive readOnly flag on the SDK listSessionEntries changed the
declaration surface, so the generated API baseline is regenerated via the
sanctioned script. Knip forbids production exports consumed only by tests:
the usage-cache testApi export is removed outright and the refresh-queue
seam moves behind an env-gated global exposed by a test-support module.
health.plugins.test's full session-accessor mock gains the newly imported
readonly listing.
* fix(plugins): load newest managed generation when a prior install lingers
An upgrade writes a plugin's new version into a distinct managed project
directory (an `__openclaw-generation__` dir) without removing the previous one.
The persisted install record keeps pointing at the older, still-present
directory, so `mergeRecoveredManagedNpmRecord` falls through to returning the
persisted record and the runtime imports the stale version even though the newer
generation is installed. `isUnavailableManagedNpmInstallRecord` only self-heals
when the persisted path is gone, so an upgrading install (both directories
present) is not covered — `plugins inspect <id> --runtime` reports the old
version and `install`/`update` appear to silently no-op.
- Recovery now selects the highest-version record per plugin id across the
legacy flat dir and any generation dirs, instead of whichever project root
sorts last (generation-dir suffixes are hashes, so on-disk order is unrelated
to version).
- When the recovered managed install is a strictly newer version at a different
path, repoint to it, reusing the metadata merge the unavailable-path branch
uses. The repoint is gated to persisted records that live inside the managed
npm root, so intentional custom/outside npm installs are left untouched.
This is a recovery-layer self-heal, so it also repairs installs that already
diverged from an earlier upgrade.
Refs #107228
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugins): honor active managed generations
Recover ambiguous managed installs by package recency only when no usable active ledger path exists, and let doctor retire non-authoritative generations safely.
Co-authored-by: Peter Lindsey <peter@lindsey.jp>
* test(plugins): use shared temp cleanup
* fix(plugins): compare active paths per platform
* fix(plugins): use managed install timestamps
* test(plugins): timestamp managed project roots
* test(plugins): exclude retired legacy recovery
* refactor(plugins): keep recovery candidate private
---------
Co-authored-by: Peter Lindsey <peter@lindsey.jp>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* feat(ui): who's-online roster menu on the footer facepile
Clicking the sidebar footer facepile now opens a scrollable roster of
everyone online — avatar, name, and email subtitle per person, with your
own entry pinned first and marked (you). Uses the stock menu-surface +
wa-dropdown idiom; long rosters scroll inside the popup via the dropdown's
menu part. Session-row facepiles stay non-interactive so row clicks keep
navigating.
* fix(ui): roster menu interaction hardening from review
- close explicitly on wa-select (preventDefault also cancels the
dropdown's own select-and-hide)
- clear the open state when a presence update unmounts the footer
facepile, so the menu cannot remount stale when presence returns
- restore focus to the visible facepile button on keyboard dismissal
(the dropdown's own trigger is a hidden throwaway anchor)
* fix(ui): return focus to the facepile trigger on roster row selection
The About page commit row now embeds the commit's committer timestamp
(commitAt, advisory like branch/dirty) and renders it as an always-relative
age with the exact localized date-time on hover. The copy button becomes a
compact borderless icon revealed on hover/focus only where hover exists;
touch keeps it visible.