Skip to content

feat(node-ui): chat panel UX/UI revamp PR2 — composer + drag-drop + chips#504

Merged
Jurij89 merged 20 commits into
mainfrom
feat/chat-ui-revamp-composer
May 14, 2026
Merged

feat(node-ui): chat panel UX/UI revamp PR2 — composer + drag-drop + chips#504
Jurij89 merged 20 commits into
mainfrom
feat/chat-ui-revamp-composer

Conversation

@Jurij89
Copy link
Copy Markdown
Contributor

@Jurij89 Jurij89 commented May 13, 2026

Summary

PR2 of 3 in the node-ui chat panel revamp. Stacked on PR1 (now squash-merged) — base is main.

  • Composer is now a clean horizontal pill: <Paperclip> attach · <TextareaAutosize minRows={1} maxRows={8}> · filled <ArrowUp> send. Three children, align-items: flex-end, pills stay glued to the bottom as the textarea grows.
  • Project picker moved BELOW the composer into its own .v10-composer-toolbar row.
  • Drag-and-drop file upload via react-dropzone, scoped to the messages region. Two-state .v10-drop-overlay (accept / refuse) with <Upload> / <Ban> icons. Screen-reader announces via role="status" + aria-live="polite".
  • Compact attachment chips replace the full-width item rows — 12px radius, file-type badge, name (ellipsis), size · status meta, <X> close at 24×24 WCAG-AAA hit target.
  • Lucide icons (MoreHorizontal, ChevronDown, Paperclip, ArrowUp, X, Upload, Ban) replace all Unicode glyphs (⋯ ▾ 📎) across PR1 + PR2 components.
  • Keyboard: IME composition guard via e.nativeEvent.isComposing; Enter/Cmd+Enter/Shift+Enter/Esc wired per ux-lead's spec.
  • WCAG AA: drop-overlay hint copy + textarea placeholder both raised from tertiary→secondary text color (all six combinations now ≥5.02:1).

Deferred (intentionally out of scope)

  • Select typeahead (deferred from PR1).
  • Send-button spinner when attachments uploading, stop-icon when streaming (needs streaming-abort infra).
  • Composer toolbar sparseness fix (single-child after picker move; "+ Tools" popover or moved hint copy).
  • Polish: chip remove icon contrast at rest, drop-overlay accept icon non-text contrast in light theme (both narrow misses on WCAG 1.4.11 3:1 non-text; not normal-text fails).
  • Full GFM markdown + shiki syntax highlighting + per-block copy button — that's PR3.

Review trail

  • ux-lead signed off with three P1 fixes after first pass. Two applied (kebab keyboard nav from PR1; chip remove 22→24px in PR2). One deferred (Select typeahead). Re-validated and approved.
  • ui-lead signed off after two contrast blockers (drop-overlay hint, textarea placeholder) — both fixed by switching to --text-secondary. All new components verified ≥AA in both themes.
  • qa-lead added 32 new unit tests (composer-autosize, dropzone, attachment-chip) and a new PR2 e2e describe block (composer auto-grow, attach-button file-picker click, dropzone overlay states, lucide icon presence). Page object + selectors helpers updated.

Test plan

  • pnpm -F @origintrail-official/dkg-node-ui test497 passed, 38 skipped, 0 failed (29 files, post round-8 Codex fixes)
  • pnpm -F @origintrail-official/dkg-node-ui test:e2e132 passed, 15 skipped, 0 failed (11 of the skips are env-dependent self-skips when no daemon)
  • tsc --noEmit -p packages/node-ui/tsconfig.json — clean
  • Manual smoke in browser: drag a file from File Explorer onto the messages region → overlay appears → drop → chip appears
  • Manual: type a long message → composer grows to 8 rows then internal-scrolls; send button stays at bottom-right
  • Manual: switch light/dark theme, verify chip + overlay + textarea placeholder legibility

Lessons captured

  • happy-dom doesn't propagate isComposing from KeyboardEventInit — set via Object.defineProperty before dispatch.
  • react-dropzone in happy-dom needs the full dragenterdragoverdrop sequence with a custom dataTransfer payload + a 30ms flush in act().
  • Two hidden <input type="file"> exist in the composer (dropzone's + attach button's) — disambiguate the attach-flow input via :not([tabindex]).

🤖 Generated with Claude Code

Jurij Skornik and others added 2 commits May 13, 2026 21:00
…ustom Select

Replaces the sticky/overlay composer with a proper flex column so the panel
header (mode tabs + agent subtabs) stays pinned and the messages region
scrolls beneath. Persists sidebar widths across reloads. Consolidates the
3-row header into 2 rows by moving Refresh/Disconnect into a kebab (⋯)
overflow menu on the active agent tab. Replaces the native <select> project
picker with a portaled, theme-aware custom Select component to fix the
dark-mode white-on-white contrast bug.

Layout
- stores/layout.ts: persist leftWidth/rightWidth/collapsed to `dkg-layout`
  localStorage key, debounced 150ms; private-mode/quota writes swallowed.
- styles.css: `.v10-panel-right` and `.v10-agents-tab` are flex columns;
  `.v10-local-agent-messages` is `flex:1; overflow-y:auto`; composer is
  `flex-shrink:0`, no longer `position:sticky`.

Header consolidation
- PanelRight.tsx: new `AgentTabMenu` sub-component renders the ⋯ trigger
  next to the active agent tab. Popover holds status row + Refresh +
  Disconnect, mirroring the existing notification dropdown pattern.
- Full keyboard support: ArrowDown/Enter/Space to open + focus first item;
  ArrowUp/Down cycle menuitems; Escape returns focus to trigger; Tab falls
  through. Status row is `aria-hidden`, click handlers route through
  `closeAndReturnFocus()`.

Custom Select
- components/common/Select.tsx: portaled trigger + menu, auto-flips above
  when no room below; arrow/Home/End/Enter/Esc keyboard nav; viewport-aware
  positioning via `getBoundingClientRect`. Same shadow/radius/border as the
  notification dropdown for visual consistency.

Empty-state copy
- Split the four agent-tab empty states into title + hint pairs per UX
  spec; legacy bare-text empty states preserved via `:not(:has(…))`.

WCAG AA
- New `--text-danger` / `--text-danger-hover` tokens (dark `#fca5a5`/`#fecaca`,
  light `#b91c1c`/`#991b1b`). Replaces hardcoded reds on the kebab Disconnect
  item and the legacy `.v10-agents-refresh.disconnect`.
- `.v10-agent-tab-menu-status` and `.v10-select-value.placeholder` move from
  `--text-tertiary` to `--text-secondary` so functional labels clear 4.5:1
  in both themes.

Tests
- New: `test/layout-persistence.test.ts`, `test/agent-tab-menu.test.ts`,
  `test/select.test.ts`.
- Updated existing assertions to match the new markup (kebab trigger
  replaces the inline toolbar; native <select> replaced by custom Select
  portal interaction).
- E2E: page-object now opens the kebab via `clickRefresh()`/`clickDisconnect()`;
  new agent-panel.spec covers width persistence, sticky header, kebab open,
  and light/dark-mode contrast smoke for the project picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hips

Composer is now a clean horizontal pill (attach · TextareaAutosize · send),
with the project picker below in its own toolbar row. File attachments drop
onto the messages region directly via react-dropzone; attached files appear
as compact chips. Unicode glyphs (⋯ ▾ 📎) replaced with lucide-react icons.
Builds on PR1's flex-column layout.

Composer
- @origintrail-official/dkg-node-ui adds: react-textarea-autosize,
  react-dropzone, lucide-react.
- `<TextareaAutosize minRows={1} maxRows={8}>` replaces the fixed 3-row
  textarea. Composer rests single-line; auto-grows; internal scroll once
  at maxRows. align-items: flex-end keeps the attach/send pills glued to
  the bottom as the textarea grows.
- Attach button (`.v10-composer-attach`) is the leftmost child of
  `.v10-local-agent-composer-shell` — 28×28 transparent circular,
  `<Paperclip size={14} />`. Send button (`.v10-local-agent-inline-send`)
  is rightmost — 28×28 filled `--text-primary` / `--bg-root` circle with
  `<ArrowUp size={14} />`. Both 28×28 satisfy WCAG 2.5.5.
- Composer shell: 6px padding, 14px radius, `--border-default`,
  `:focus-within` → `--border-strong`.
- Project picker moved BELOW the composer-shell into a new
  `.v10-composer-toolbar` row. The old above-composer
  `.v10-local-agent-toolbar` block is removed; the empty
  `toolbar-actions` placeholder goes with it.

Keyboard
- IME composition guard: `e.nativeEvent.isComposing` early-return at the
  top of `onKeyDown` (closes the CJK/JP Enter-during-IME-confirmation bug).
- `Enter` (no modifiers) → send.
- `Cmd/Ctrl+Enter` → force-send when text empty + attachments queued.
- `Escape` (text non-empty) → clear textarea, retain focus. Falls through
  to the panel's popover-close handlers when textarea is empty.
- `Shift+Enter` continues to insert a newline (default browser behavior).

Drag-and-drop
- `useDropzone({ onDrop, noClick: true, noKeyboard: true })` scoped to
  `.v10-local-agent-messages` only — drop-only, paperclip remains the
  click-to-attach path.
- New `.v10-drop-overlay` with two states (`.accept` / `.refuse`):
  - accept: 2px dashed `--accent-blue` border, blue-tinted bg, `<Upload />`
    icon, title "Drop files to attach to {projectName}",
    hint "Release to upload to this conversation."
  - refuse: 2px dashed `--border-strong`, neutral tint, `<Ban />` icon,
    title "Choose a project before attaching files." (secondary text),
    hint "Use the picker below the composer."
- Opacity transition 80ms ease-out; `pointer-events: none` until `.active`.
- `role="status"` + `aria-live="polite"` for screen-reader announcements.
- `@media (prefers-reduced-motion: reduce)` disables transition.

Attachment chips
- Compact chip pattern replaces the full-width item rows.
- `.v10-attachment-chip` — 12px radius, 8px padding, `--bg-elevated` bg,
  `--border-subtle` border; horizontal flex-wrap container with 8px gap.
- 32×32 badge (TXT/PDF/IMG/CODE/DOC/FILE) on `--bg-active` background.
- Name (180px max-width, ellipsis); meta row "size · status" 10px tertiary.
- `data-status` attribute tints the border (red for error, blue for
  uploading) and the status text (danger color on error).
- 24×24 circular remove button with `<X size={12} />` (24px hit target
  per WCAG 2.5.5 AAA, per ux-lead).

Icons
- `<MoreHorizontal />` replaces `⋯` in the kebab tab menu trigger.
- `<ChevronDown />` replaces `▾` in the Select caret.
- `<Paperclip />`, `<ArrowUp />`, `<X />`, `<Upload />`, `<Ban />` for
  composer/chip/overlay.

WCAG AA (PR2 review fixes from ui-lead)
- `.v10-drop-overlay-hint`: `--text-tertiary` → `--text-secondary` so the
  guidance copy clears 4.5:1 on both accept and refuse washes in both
  themes (lowest combination 5.02:1).
- `.v10-agent-input::placeholder`: `--text-tertiary` → `--text-secondary`
  so dynamic bridge-state placeholders ("OpenClaw is still connecting…",
  "OpenClaw bridge offline…", etc.) clear 4.5:1 (dark 5.33:1, light 7.81:1).

Tests
- Updated existing assertions to match the new markup (icon-only send
  queried by `aria-label="Send message"`; attach flow disambiguated from
  the dropzone's hidden input via `:not([tabindex])`; chip remove by
  `.v10-attachment-chip-remove`).
- New: `test/composer-autosize.test.ts` (12 tests covering IME guard,
  Enter / Ctrl+Enter / Esc / Send wiring).
- New: `test/dropzone.test.ts` (6 tests covering accept/refuse states +
  the gating conditions inside `handleFilesDrop`).
- New: `test/attachment-chip.test.ts` (14 tests across all five status
  variants + remove flow + multi-target hint).
- E2E: new PR2 describe block in `agent-panel.spec.ts` for composer
  auto-grow, attach-button file-picker click, dropzone overlay states,
  and lucide-icon presence. Selectors + page-object helpers added.

Lessons captured (worktree-local `agent-docs/notes/lessons.md`)
- Two hidden file inputs in the chat shell — disambiguate via
  `:not([tabindex])`.
- happy-dom doesn't propagate `isComposing` from `KeyboardEventInit`;
  set via `Object.defineProperty` before dispatch.
- react-dropzone in happy-dom needs the full `dragenter` → `dragover` →
  `drop` sequence with a custom-attached `dataTransfer` + a 30ms flush.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx Outdated
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx Outdated
Comment thread packages/node-ui/test/composer-autosize.test.ts Outdated
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx Outdated
Comment thread packages/node-ui/e2e/specs/agent-panel.spec.ts Outdated
Jurij Skornik and others added 3 commits May 14, 2026 14:30
… e2e checks

Addresses 4 findings from the Codex review on #503:

(#1, layout.ts:46) — Persisted widths are now clamped to the same bounds
the live drag handlers enforce (140-400 left, 200-500 right). A stale or
hand-edited `dkg-layout` entry like `{"leftWidth": 2000}` would have
reloaded the shell into an unusable state with no UI recovery; on load
we now coerce it back into the legal range. New unit test seeds an
out-of-range entry and asserts the clamp.

(#2, PanelRight.tsx:1163) — The custom Select replacement dropped the
implicit empty option the native `<select>` used to ship, leaving no UI
path back to "no project selected" once the user picked one. Prepended a
"No project (clear selection)" option that emits `''` so
handleSelectProject() can still clear the target.

(#3, agent-panel.spec.ts:184) — The sticky-header e2e set
`scrollTop = 9999` on a `.v10-agents-tab / .v10-agent-content` element
that became `overflow: hidden` in PR1 (the actual scrolling moved into
`.v10-local-agent-messages`). The assertion therefore passed vacuously.
Rewrote as a structural check: the mode-tabs element must be a flex
sibling (not a descendant) of the scrollable messages region, which is
the actual sticky-by-construction contract this PR introduces.

(#4, agent-panel.spec.ts:232) — The project-picker dark-mode test
asserted against a snapshot baseline `project-select-dark.png` that
wasn't committed, so CI would have failed on the missing image rather
than catching a regression. Replaced with a DOM/style check: the
trigger's computed `color` must not equal its computed
`background-color` — same intent (kill the white-on-white bug) without
the snapshot-asset dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/node-ui/src/ui/components/Shell/PanelRight.tsx
…utosize tests

Addresses 3 findings from the Codex review on #504:

(#6, PanelRight.tsx:1071) — `attachmentsEnabled` was false for three
different reasons (no project, in-flight send, or integration that doesn't
support attachments), but the refuse-state overlay always blamed the
missing project. Surface the actual rejection: introduce
`dropDisabledReason` ('unsupported' | 'noProject' | 'sending' | null) and
render matching title + hint copy for each so the user can take the right
recovery action.

(#7, composer-autosize.test.ts:131) — The previous assertion
`style.height === '' || style.height.length >= 0` was tautologically true
for any string and didn't verify autosize was wired. Mock
`react-textarea-autosize` at the module level to capture forwarded props
and assert `minRows: 1` + `maxRows: 8` directly. Survives happy-dom (no
layout) without depending on DOM attributes the real library doesn't
forward.

(#8, agent-panel.spec.ts:284) — The clamp assertion was tied to current
font-derived metrics (`grown * 6 + 16`), making it flaky on CI font
substitutions and not actually checking the PR contract. Reworked to
assert the behavior the PR is meant to enforce: heightAt(12 lines) ≈
heightAt(8 lines), with ±2px slack for sub-pixel rounding, plus
overflowY ∈ {auto, scroll}. The check now fails iff `maxRows={8}` itself
regresses, not when the line-height changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx Outdated
Comment thread packages/node-ui/src/ui/styles.css
Comment thread packages/node-ui/test/openclaw-bridge.test.ts Outdated
Jurij Skornik and others added 3 commits May 14, 2026 15:05
…ectors

The custom <Select> renders its dropdown via createPortal to document.body
so the menu/option DOM lives OUTSIDE `.v10-local-agent-target-select`.
The descendant selectors `.v10-local-agent-target-select .v10-select-menu`
and `.v10-local-agent-target-select .v10-select-option` would never match,
so any e2e check using them would fail even when the picker works.

Switched both to top-level class selectors (`.v10-select-menu` /
`.v10-select-option`) so Playwright actually finds them when the picker
is open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This test asserted that the panel-right.tsx source contains the literal
string `Paperclip` (the lucide import name). Codex flagged this as too
implementation-coupled — swapping icon libraries or renaming the import
alias would break the test even when the user-visible composer still
worked.

Removed the assertion. The surrounding checks already cover the
user-visible contract (composer-attach className, "Attach files"
aria-label, composer-toolbar wrapper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx Outdated
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx Outdated
Jurij Skornik and others added 4 commits May 14, 2026 15:22
…ome/End, always-clearable picker

Three Codex round-3 review fixes on PR #503:

- CEcBz: clamp left/right widths inside setLeftWidth / setRightWidth, not
  only on load. Stops programmatic callers (and any future drag handler
  variant) from pushing the store outside the bounds loadPersisted
  enforces — the persisted blob now matches what the store accepts on
  reload.

- CEcBv: Home / End now skip disabled options, matching ArrowUp / Down.
  Previously End could land on a trailing disabled row (e.g. a
  Loading/separator marker), making Enter a silent no-op and leaving
  the user stuck on a dead highlight.

- CEcBq: the project picker is only disabled while the project list is
  loading. Even with zero real projects, the "No project (clear
  selection)" option is always present, so the user can always reopen
  the picker to clear a stale selection.

Tests updated to match new behavior; full suite (463 / 463) green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…heck

CEdrv: the send button gates on
`inputDisabled || (!localInput.trim() && !hasSendableAttachmentDrafts)`,
but the plain-Enter branch in the textarea onKeyDown handler called
`onSendLocalMessage()` unconditionally. Pressing Enter on an empty
composer (or while inputDisabled) would fire a no-op send even though
the visible send affordance was disabled — confusing, and a divergence
from the established keyboard contract.

Plain Enter now mirrors the same gate as the send button. Ctrl/Meta+
Enter already had this check.

Adds two coverage tests:
- plain Enter does NOT send when input is empty / whitespace and no
  attachments are queued
- plain Enter DOES send when text is empty but attachments are queued
  (matches the Ctrl+Enter behavior — attachments alone are a valid
  payload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on when needed

BN3mu: the picker's value is `activeProjectId ?? ''`. With the synthetic
`{ value: '', label: 'No project (clear selection)' }` always at the
top of the options list, the trigger label became "No project (clear
selection)" whenever no project was active — masking the
"Choose a project" / "Loading projects…" placeholder and looking like a
real selection.

Render the synthetic clear option only when `activeProjectId` is truthy:
the row exists exactly when there is something to clear, the empty
trigger value no longer matches any option in the cleared state, and the
placeholder surfaces correctly.

Picker disabled condition updated to match: disabled while loading OR
when nothing is active AND no real projects exist (no rows to pick).
When a stale active project is set, the clear row is always present, so
CEcBq's "user must always be able to clear a stale selection" rule
still holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/node-ui/src/ui/components/Shell/PanelRight.tsx
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx Outdated
Comment thread packages/node-ui/test/openclaw-bridge.test.ts Outdated
Jurij Skornik and others added 3 commits May 14, 2026 15:49
…ck guard

BOMw6: option clicks ignored the component-level `disabled` state. If
the parent flipped `disabled` to `true` while the menu was open, the
trigger greyed out but the portal-rendered menu remained mounted and
interactive — users could still select stale options.

Two-pronged guard:

- A new effect closes the menu when `disabled` becomes true while open,
  bringing the menu state in line with the trigger's affordance.

- The option `onClick` re-reads `disabled` so a click that races the
  effect on the same tick is still a no-op (the close-on-disabled
  effect would otherwise need a re-render to take effect).

Coverage: a new test renders the Select unmocked, opens the menu, then
re-renders with `disabled: true` and asserts the portal-rendered menu
is unmounted and `onChange` was never called.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rop brittle source-text asserts

Two follow-up fixes on PR #504:

- BNiBq / BOay0 (Critical): the iteration-1 polish narrowed the queued-
  attachment hint to `attachmentTargetIds.length > 1`, which silently
  dropped the single-target mismatch case. If a user attached under
  project A, switched the picker to B, and sent, the drafts still
  routed to A but the composer no longer surfaced that. New
  `hasMismatchedAttachmentTargets` flag re-includes that case and the
  hint now picks the right message ("…stored target (A), not the active
  project." vs the multi-project line).

- BOay7 (Issue): openclaw-bridge.test.ts asserted source-code snippets
  (the picker `value` JSX, the queued-status conditional, the fallback
  key expression). That's brittle to harmless refactors without
  catching real regressions. Replaced with the observable contract:
  className / aria-label / user-visible copy. The actual rendering
  behavior is covered by composer-autosize.test.ts,
  attachment-chip.test.ts, panel-right.{component,logic}.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/styles.css Outdated
Comment thread packages/node-ui/e2e/specs/agent-panel.spec.ts Outdated
…refuse via UI

Two follow-ups on PR #504:

- CFQfw (Critical): `.v10-drop-overlay.active` flipped to
  `pointer-events: auto`, which can steal the follow-up
  `dragenter`/`dragleave`/`drop` sequence from the dropzone root in real
  browsers and cause flicker or dropped files failing to register.
  Removed the override — the base rule's `pointer-events: none` keeps
  the overlay decorative, and the dropzone root (which owns the drag
  lifecycle) is the only interactive surface.

- CFQf2: the `refusedWithoutProject` e2e test gated on
  `window.__dkgProjects`, a global that is never exposed in
  packages/node-ui — the assertion was effectively dead. Rewrote to
  clear the active project through the picker UI (open trigger →
  click "No project (clear selection)"), with clean skip paths for
  envs where there is no project to clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx Outdated
… button

CFfZ3: the icon-only attach button had its own ad-hoc disabled gate
(`!selected?.chatAttachments || !activeProjectId || localSending`)
that duplicated — and could drift from — the dropzone's
`dropDisabledReason` state. Its tooltip also kept advertising the
generic "Attach files" affordance even when the button was disabled
for an unsupported-agent or in-flight-send reason, leaving the user
without recovery copy.

Switch the button to gate on `attachmentsEnabled` (derived from the
same shared `dropDisabledReason` chain) and pick a state-specific
tooltip — so the button and the drop overlay stay in lockstep and
the user sees the same recovery hint via either path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review completed — no issues found.

…omposer

# Conflicts:
#	packages/node-ui/e2e/helpers/selectors.ts
#	packages/node-ui/e2e/specs/agent-panel.spec.ts
#	packages/node-ui/src/ui/components/Shell/PanelRight.tsx
#	packages/node-ui/src/ui/components/common/Select.tsx
#	packages/node-ui/test/panel-right.component.test.ts
@Jurij89 Jurij89 changed the base branch from feat/chat-ui-revamp to main May 14, 2026 14:37
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review produced 1 comment(s) but all targeted lines outside the diff and were dropped. Check the workflow logs for details.

@Jurij89 Jurij89 merged commit de84aeb into main May 14, 2026
1 check passed
Jurij89 added a commit that referenced this pull request May 14, 2026
…ghting (#505)

* feat(node-ui): chat panel UX/UI revamp PR1 — layout, sticky header, custom Select

Replaces the sticky/overlay composer with a proper flex column so the panel
header (mode tabs + agent subtabs) stays pinned and the messages region
scrolls beneath. Persists sidebar widths across reloads. Consolidates the
3-row header into 2 rows by moving Refresh/Disconnect into a kebab (⋯)
overflow menu on the active agent tab. Replaces the native <select> project
picker with a portaled, theme-aware custom Select component to fix the
dark-mode white-on-white contrast bug.

Layout
- stores/layout.ts: persist leftWidth/rightWidth/collapsed to `dkg-layout`
  localStorage key, debounced 150ms; private-mode/quota writes swallowed.
- styles.css: `.v10-panel-right` and `.v10-agents-tab` are flex columns;
  `.v10-local-agent-messages` is `flex:1; overflow-y:auto`; composer is
  `flex-shrink:0`, no longer `position:sticky`.

Header consolidation
- PanelRight.tsx: new `AgentTabMenu` sub-component renders the ⋯ trigger
  next to the active agent tab. Popover holds status row + Refresh +
  Disconnect, mirroring the existing notification dropdown pattern.
- Full keyboard support: ArrowDown/Enter/Space to open + focus first item;
  ArrowUp/Down cycle menuitems; Escape returns focus to trigger; Tab falls
  through. Status row is `aria-hidden`, click handlers route through
  `closeAndReturnFocus()`.

Custom Select
- components/common/Select.tsx: portaled trigger + menu, auto-flips above
  when no room below; arrow/Home/End/Enter/Esc keyboard nav; viewport-aware
  positioning via `getBoundingClientRect`. Same shadow/radius/border as the
  notification dropdown for visual consistency.

Empty-state copy
- Split the four agent-tab empty states into title + hint pairs per UX
  spec; legacy bare-text empty states preserved via `:not(:has(…))`.

WCAG AA
- New `--text-danger` / `--text-danger-hover` tokens (dark `#fca5a5`/`#fecaca`,
  light `#b91c1c`/`#991b1b`). Replaces hardcoded reds on the kebab Disconnect
  item and the legacy `.v10-agents-refresh.disconnect`.
- `.v10-agent-tab-menu-status` and `.v10-select-value.placeholder` move from
  `--text-tertiary` to `--text-secondary` so functional labels clear 4.5:1
  in both themes.

Tests
- New: `test/layout-persistence.test.ts`, `test/agent-tab-menu.test.ts`,
  `test/select.test.ts`.
- Updated existing assertions to match the new markup (kebab trigger
  replaces the inline toolbar; native <select> replaced by custom Select
  portal interaction).
- E2E: page-object now opens the kebab via `clickRefresh()`/`clickDisconnect()`;
  new agent-panel.spec covers width persistence, sticky header, kebab open,
  and light/dark-mode contrast smoke for the project picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(node-ui): chat panel UX/UI revamp PR2 — composer + drag-drop + chips

Composer is now a clean horizontal pill (attach · TextareaAutosize · send),
with the project picker below in its own toolbar row. File attachments drop
onto the messages region directly via react-dropzone; attached files appear
as compact chips. Unicode glyphs (⋯ ▾ 📎) replaced with lucide-react icons.
Builds on PR1's flex-column layout.

Composer
- @origintrail-official/dkg-node-ui adds: react-textarea-autosize,
  react-dropzone, lucide-react.
- `<TextareaAutosize minRows={1} maxRows={8}>` replaces the fixed 3-row
  textarea. Composer rests single-line; auto-grows; internal scroll once
  at maxRows. align-items: flex-end keeps the attach/send pills glued to
  the bottom as the textarea grows.
- Attach button (`.v10-composer-attach`) is the leftmost child of
  `.v10-local-agent-composer-shell` — 28×28 transparent circular,
  `<Paperclip size={14} />`. Send button (`.v10-local-agent-inline-send`)
  is rightmost — 28×28 filled `--text-primary` / `--bg-root` circle with
  `<ArrowUp size={14} />`. Both 28×28 satisfy WCAG 2.5.5.
- Composer shell: 6px padding, 14px radius, `--border-default`,
  `:focus-within` → `--border-strong`.
- Project picker moved BELOW the composer-shell into a new
  `.v10-composer-toolbar` row. The old above-composer
  `.v10-local-agent-toolbar` block is removed; the empty
  `toolbar-actions` placeholder goes with it.

Keyboard
- IME composition guard: `e.nativeEvent.isComposing` early-return at the
  top of `onKeyDown` (closes the CJK/JP Enter-during-IME-confirmation bug).
- `Enter` (no modifiers) → send.
- `Cmd/Ctrl+Enter` → force-send when text empty + attachments queued.
- `Escape` (text non-empty) → clear textarea, retain focus. Falls through
  to the panel's popover-close handlers when textarea is empty.
- `Shift+Enter` continues to insert a newline (default browser behavior).

Drag-and-drop
- `useDropzone({ onDrop, noClick: true, noKeyboard: true })` scoped to
  `.v10-local-agent-messages` only — drop-only, paperclip remains the
  click-to-attach path.
- New `.v10-drop-overlay` with two states (`.accept` / `.refuse`):
  - accept: 2px dashed `--accent-blue` border, blue-tinted bg, `<Upload />`
    icon, title "Drop files to attach to {projectName}",
    hint "Release to upload to this conversation."
  - refuse: 2px dashed `--border-strong`, neutral tint, `<Ban />` icon,
    title "Choose a project before attaching files." (secondary text),
    hint "Use the picker below the composer."
- Opacity transition 80ms ease-out; `pointer-events: none` until `.active`.
- `role="status"` + `aria-live="polite"` for screen-reader announcements.
- `@media (prefers-reduced-motion: reduce)` disables transition.

Attachment chips
- Compact chip pattern replaces the full-width item rows.
- `.v10-attachment-chip` — 12px radius, 8px padding, `--bg-elevated` bg,
  `--border-subtle` border; horizontal flex-wrap container with 8px gap.
- 32×32 badge (TXT/PDF/IMG/CODE/DOC/FILE) on `--bg-active` background.
- Name (180px max-width, ellipsis); meta row "size · status" 10px tertiary.
- `data-status` attribute tints the border (red for error, blue for
  uploading) and the status text (danger color on error).
- 24×24 circular remove button with `<X size={12} />` (24px hit target
  per WCAG 2.5.5 AAA, per ux-lead).

Icons
- `<MoreHorizontal />` replaces `⋯` in the kebab tab menu trigger.
- `<ChevronDown />` replaces `▾` in the Select caret.
- `<Paperclip />`, `<ArrowUp />`, `<X />`, `<Upload />`, `<Ban />` for
  composer/chip/overlay.

WCAG AA (PR2 review fixes from ui-lead)
- `.v10-drop-overlay-hint`: `--text-tertiary` → `--text-secondary` so the
  guidance copy clears 4.5:1 on both accept and refuse washes in both
  themes (lowest combination 5.02:1).
- `.v10-agent-input::placeholder`: `--text-tertiary` → `--text-secondary`
  so dynamic bridge-state placeholders ("OpenClaw is still connecting…",
  "OpenClaw bridge offline…", etc.) clear 4.5:1 (dark 5.33:1, light 7.81:1).

Tests
- Updated existing assertions to match the new markup (icon-only send
  queried by `aria-label="Send message"`; attach flow disambiguated from
  the dropzone's hidden input via `:not([tabindex])`; chip remove by
  `.v10-attachment-chip-remove`).
- New: `test/composer-autosize.test.ts` (12 tests covering IME guard,
  Enter / Ctrl+Enter / Esc / Send wiring).
- New: `test/dropzone.test.ts` (6 tests covering accept/refuse states +
  the gating conditions inside `handleFilesDrop`).
- New: `test/attachment-chip.test.ts` (14 tests across all five status
  variants + remove flow + multi-target hint).
- E2E: new PR2 describe block in `agent-panel.spec.ts` for composer
  auto-grow, attach-button file-picker click, dropzone overlay states,
  and lucide-icon presence. Selectors + page-object helpers added.

Lessons captured (worktree-local `agent-docs/notes/lessons.md`)
- Two hidden file inputs in the chat shell — disambiguate via
  `:not([tabindex])`.
- happy-dom doesn't propagate `isComposing` from `KeyboardEventInit`;
  set via `Object.defineProperty` before dispatch.
- react-dropzone in happy-dom needs the full `dragenter` → `dragover` →
  `drop` sequence with a custom-attached `dataTransfer` + a 30ms flush.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(node-ui): chat panel UX/UI revamp PR3 — markdown + syntax highlighting

Replaces the hand-rolled regex renderer (which handled only **bold** and
`inline code`) with proper GFM markdown via react-markdown + remark-gfm,
remark-breaks, and lazy-loaded shiki for syntax highlighting on fenced code
blocks. Builds on PR2's composer/attachments.

Components
- New `src/ui/components/chat/MarkdownMessage.tsx` — react-markdown wrapper
  with `remarkPlugins={[remarkGfm, remarkBreaks]}`. Custom renderers route
  h1-h6/p/ul/ol/li/a/blockquote/hr/table/thead/tbody/tr/th/td/pre/code into
  `.v10-md-*` classed elements. Default react-markdown sanitization is
  preserved (no rehype-raw / rehype-sanitize) — agent output is untrusted;
  `<script>` and inline event handlers are stripped on the way in.
- New `src/ui/components/chat/CodeBlock.tsx` — fenced code blocks. Lazy
  `import('shiki')` only fires when a CodeBlock actually mounts (zero
  bundle weight for histories without code). Curated language list
  (ts, tsx, js, jsx, py, sh, bash, json, yaml, sql, sparql, md, html, css)
  + aliases. github-dark / github-light theme follows `body.light`. Plain
  `<pre><code>` fallback rendered while shiki resolves AND for unsupported
  languages — single render path, no error branch. Hover-revealed copy
  button (`<Copy>` → `<Check>` 1200ms flash, `aria-live="polite"`
  announcement, silent failure on clipboard rejection). Touch and
  `prefers-reduced-motion` both pin the button to always-visible.
- Streaming cursor (`<span class="v10-chat-cursor">`) renders as a sibling
  after `<MarkdownMessage>` for v1; spec-aligned with the plan.

Wiring
- `PanelRight.tsx`: `renderMessageContent` returns `<MarkdownMessage>`.
  `normalizeMessageContent` stays as the pre-pass (strips leading/trailing
  blank lines, normalizes CRLF/escaped \n).
- All `<a>` rendered with `target="_blank" rel="noopener noreferrer"`.
  Both rel tokens are present in the order required by the OWASP guidance
  (`noopener` prevents `window.opener` access; `noreferrer` strips the
  Referer header).
- `remark-breaks` added to mirror chat-UI convention (ChatGPT / Claude /
  Cursor): single newlines render as `<br>`, since agents emit
  newline-separated lines liberally without strict paragraph breaks.

Styles
- New `.v10-md-*` namespace in `styles.css`. Body 12px / line-height 1.5.
  Headings: 18/16/14/13/12/11px; line-height 1.3 (h1-h3), 1.4 (h4-h6);
  h6 doubles as an eyebrow label (`text-transform: uppercase`, secondary
  color). Lists 20px indent, task lists via remark-gfm checkbox accent.
  Blockquote 3px `--border-strong` left rail with `--text-tertiary 5%`
  tint. Tables wrapped in `.v10-md-table-scroll` for horizontal scroll;
  `--border-subtle` cell borders; `--bg-elevated` thead.
- Inline code (`.v10-md-code`): `--bg-active` background, mono 11px,
  `1px 5px` padding, 4px radius, 1px `--border-prominent` border (so the
  chip stays visible on bubbles whose bg is also `--bg-active` — clears
  3:1 non-text on every bubble × theme combination).
- Code block container: 12px radius (matches chip/composer family),
  `--bg-elevated` background, language pill top-left, copy button
  top-right. Shiki `<pre>` background neutralized via `background:
  transparent !important` so highlighted output inherits the container.

WCAG AA (PR3 review fixes from ui-lead)
- New `--text-link` token (dark `#60a5fa` / light `#1d4ed8`); replaces
  `--accent-blue` on `.v10-md-link` so links clear 4.5:1 on every
  bubble × theme combination. `--accent-blue` retained for signal-use
  (status dots, focus rings, washes) where 3:1 non-text applies.
- New `--border-prominent` token (dark `#888888` / light `#525252` — matches
  `--text-secondary` per-theme values). Used on the inline-code chip
  border so visibility is bubble-independent.
- `.v10-md-pre-lang` language label: `--text-tertiary` → `--text-secondary`,
  `--bg-active` → `--bg-elevated`. Clears 5.01:1 dark / 6.86:1 light.

Tests
- New: `test/markdown-message.test.ts` (18 tests covering all GFM features,
  G1 script sanitization, G2 link rel attributes, G3 plaintext fallback,
  remark-breaks behavior, lazy-shiki contract via `vi.mock` counter).
- New: `test/code-block.test.ts` (9 tests covering fallback render, copy
  button aria states, clipboard wiring, 1200ms flash + revert via
  `vi.advanceTimersByTimeAsync`, silent clipboard-failure, language tag
  normalization).
- Updated: `test/panel-right.logic.test.ts` legacy fixture now asserts the
  new react-markdown markup (`<p class="v10-md-p">` wrapping, `.v10-md-code`
  on inline code, `<br>` from remark-breaks).
- E2E: new `PR3: markdown rendering` describe block in `agent-panel.spec.ts`
  with `rendersTable`, `codeBlockCopy`, `linkOpensExternal`, and
  `scriptSanitized` specs. Selectors helper gets `.v10-md-*` entries.

Lessons captured (worktree-local `agent-docs/notes/lessons.md`)
- react-markdown v9+ sanitizes raw HTML by default — never add `rehype-raw`.
- The "shiki only loads on demand" contract is testable via `vi.mock` factory
  counter; assert count === 0 for code-block-free fixtures.
- Fake-timer sequencing for the copy-button flash: click → resolve writeText
  → resolve setCopied → assert Copied → `advanceTimersByTimeAsync(1300)`
  → assert reverted. Don't `runAllTimers()`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): iteration 1 polish — composer, drag-drop, kebab, chip wrap, tabs

Addresses first-pass user testing feedback against PR1 + PR2 + PR3:

Composer (PR2 layout revisit — Claude-Desktop / Cursor style)
- `.v10-local-agent-composer-shell` is now a vertical flex stack: textarea
  on top using the full width of the box, controls toolbar
  (`.v10-composer-controls`) below inside the same rounded shell.
- The attach button (paperclip), project picker, and send button live in
  that single controls row. The separate `.v10-composer-toolbar` row that
  hung below the shell is gone — its job is now inside the shell.
- Trailing "Choose a project to attach files." hint copy retired — the
  picker now sits visibly next to the gated attach button, and the
  paperclip's `title` still spells out the precondition on hover.

Project picker affordance
- New optional `prefixIcon` prop on the custom Select. The project picker
  passes a lucide `<Folder size={12} />` so the selected value reads as
  a project name, not just a free-floating string.
- `.v10-select-prefix` styled to inherit `--text-secondary`.

Drag-and-drop scope
- `useDropzone({ noClick, noKeyboard })` now binds to
  `.v10-local-agent-chat-shell` (above + composer), not just the messages
  region. Drops on the composer area now register; the overlay still
  renders inside the messages region for visual clarity.

Attachment chip lifecycle
- `clearCompletedAttachmentsForConversation` runs immediately after the
  user-message bubble is pushed, instead of waiting for the assistant
  reply to finish streaming. Chips no longer linger as "Ready" while the
  user waits for a response.

Inline-code wrap spacing (PR3 polish)
- `.v10-md-code` gets `box-decoration-break: clone` + a slight vertical
  padding bump, so wrapped chips (long URNs, file paths) don't visually
  glue to the line above/below.
- Paragraphs and list items that contain inline-code chips bump
  line-height to 1.75 via `:has()` so the stacked chips breathe.

Kebab popover positioning
- `.v10-agent-tab-menu-popover` anchored to the LEFT edge of the trigger
  (`left: 0` instead of `right: 0`) so the popover opens rightward into
  the panel. Fixes the clip on left-most agent tabs (e.g., OpenClaw alone
  in the tab row).

Network + Sessions tabs
- New `.v10-agent-scroll-tab` wrapper class with `padding: 12px` +
  `overflow-y: auto`. Restores the edge padding that disappeared when
  `.v10-agents-tab` became a padding-less flex column in PR1.
- NetworkTab and SessionsTab swap their root classNames over.

Tests
- `openclaw-bridge.test.ts`: source-grep assertions updated
  (`v10-composer-toolbar` → `v10-composer-controls`; retired-hint source
  string swapped for the surviving paperclip `title` attribute).
- `e2e/helpers/selectors.ts`: `composerToolbar` selector renamed to
  `composerControls`.
- All 520 unit tests pass; no e2e selectors broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): iteration 2 polish — drop overlay, borderless picker + send, option tooltips

Round-two user feedback against the iteration-1 polish:

Drop-overlay → Claude-Desktop-style affordance
- Overlay now covers the entire `.v10-local-agent-chat-shell` (messages
  region + composer area), not just the messages strip. Drag-over
  anywhere in the chat now produces a visible cue regardless of which
  band the cursor enters first.
- New `.v10-drop-overlay-card` — centered rounded card with 32px icon and
  the title/hint stack. Backdrop is `color-mix(--bg-root 60%, transparent)`
  + 2px backdrop-blur for the dim-the-chat affordance Claude uses.
- Accept (`accent-blue` dashed border) / refuse (`border-strong` dashed)
  variants preserved; both render the same card structure.
- The `.v10-local-agent-chat-shell` gets `position: relative` to anchor
  the absolute overlay.

Composer trim: borderless project picker + send
- The send button JSX previously carried both `v10-agent-send-btn` and
  `v10-local-agent-inline-send`. The base class is defined LATER in the
  cascade and re-stamps a 1px `--border-default` outline. Dropped the
  base class on the send button — `v10-local-agent-inline-send` is now
  the sole, complete rule (filled circle, no border, 28×28).
- Project picker (`.v10-composer-target .v10-select-trigger`) now sits
  borderless and transparent at rest. Hover / open both lift to
  `--bg-hover`. Matches the ghost feel of `.v10-composer-attach` so the
  composer controls row reads as a single calm row of icons, not three
  visually competing widgets.
- Project picker `max-width` bumped 220 → 280px so most project names
  fit without truncation.

Long project names — option tooltips
- `<Select>` options now carry a `title={opt.label}` attribute so long
  project names surface in the native hover tooltip when the visible
  label gets ellipsized. No new component, no extra library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): inline-code chip open-ends on wrap (Claude-Desktop parity)

When a single \`inline code\` chip wraps across lines, the right edge of
line 1 and the left edge of line 2 should stay OPEN — signalling "this is
one continuous token that the line break sliced", not "two separate code
chips that happen to be adjacent".

- Removed the `box-decoration-break: clone` override from `.v10-md-code`
  so the chip falls back to the default `slice` behavior. With slice,
  border + background + padding act as one continuous box that the
  browser cuts at the wrap point, leaving open edges naturally.
- Bumped `.v10-md-p:has(> .v10-md-code)` / `.v10-md-li:has(> .v10-md-code)`
  line-height from 1.75 → 1.85. Slice doesn't render a full padding box
  per line, so vertical breathing room comes from line-height alone now;
  1.85 keeps the chips comfortably apart without ballooning plain prose.

Matches Claude Desktop's wrap presentation while keeping the closed-box
look for the common single-line case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): iteration 3 polish — send button inactive contrast, picker width, drop overlay monochrome

Final round of user testing feedback against the iteration-2 polish:

Send button inactive state
- Previous disabled style used `var(--bg-active)` background + `var(--text-tertiary)`
  icon — ~1.6:1 on the composer shell, basically invisible in both
  themes (light: too pale, dark: too dim).
- Swap to `var(--text-secondary)` background + `var(--bg-surface)` icon.
  Keeps the filled-circle silhouette so the disabled state visually
  echoes the active state. Contrast against the composer shell: dark
  ~4.7:1, light ~5.5:1 — clearly visible without competing with an active
  send. Icon-on-button contrast: dark ~12.7:1, light ~7.7:1.

Project picker — grow to fill available space
- `.v10-composer-target` switched from `flex: 0 1 auto; max-width: 280px`
  (content-sized + capped) to `flex: 1` with no max-width. The picker now
  takes the full available width between the attach button on its left
  and the send button anchored right. Most project names fit at default
  panel width (360px); longer ones still ellipsize and surface in the
  option's native `title` tooltip on hover.

Drop overlay — monochrome accent
- The saturated `--accent-blue` dashed border + icon on the accept-state
  drop card competed with the rest of the node design rather than
  fitting into it.
- Swap to `--text-primary` for both the border and the icon — theme-aware
  (near-white in dark mode, near-black in light mode). The refuse-state
  still uses `--border-strong` for the muted-but-readable variant.
- Matches the Claude-Desktop "monochrome modal" aesthetic the user
  pointed at.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): NetworkTab peer grouping — Connected (open) / Disconnected (collapsed)

The Network tab dumped every known peer into one long flat list, so a node
with 5 connected + 50 disconnected peers required a long scroll past
mostly-dead rows to find the live ones. Restructured into two
collapsible groups with count badges.

NetworkPeerCard (extracted)
- Pulled the per-peer card markup out of NetworkTab into its own component
  so both groups render the same card without code duplication. No
  behavior change vs the previous inline render.

NetworkPeerGroup (new)
- Collapsible section with a header (chevron + uppercase label + count
  badge), a click target spanning the full header row, and an
  `aria-expanded` attribute on the button for screen readers.
- ChevronRight icon rotates 90° when expanded; transition disabled under
  `prefers-reduced-motion: reduce`.
- Per-group empty state when the section is open but has zero matching
  peers ("No peers currently connected." / "All known peers are
  connected.") so the section doesn't look broken.

NetworkTab
- Splits `peerAgents` into `connectedPeers` (`connectionStatus ===
  'connected'`) and `disconnectedPeers` (everything else).
- `connectedExpanded` defaults to `true`, `disconnectedExpanded` defaults
  to `false` — the common case (looking at active peers) needs zero
  clicks; the historical-state case (looking at disconnected peers) is
  one click away.
- Each section header surfaces its peer count so the user knows the size
  of the collapsed group without expanding it.
- Removed the old `Network Peers` section label — the two group headers
  subsume it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): NetworkTab — dedupe peer list by peerId

The top summary ("29 peers · 4 direct / 25 relayed") reads directly from
libp2p's connection state and is authoritative. The Connected /
Disconnected group counts read from the /api/agents feed, which can
report the same physical peer under multiple records (e.g. when polling
overlaps mid-handoff or when a peer is reachable via more than one
transport). Result: a Connected-section count of 43 vs a top-summary
count of 29.

Dedupe the peerAgents array by `peerId` before splitting into the two
groups, preferring the most-recently-seen record so latency/status
reflect current state. The on-screen counts now match libp2p reality
without changing the fetch endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): NetworkTab — derive top summary counts from deduped peer list

After deduping by peerId in the previous commit, the section counts still
disagreed with the top summary (17 peers from libp2p vs 14 in the
Connected list). Root cause: /api/connections counts *connections* —
a peer reachable on both direct and relayed transports counts twice — so
the libp2p total is naturally higher than the unique-peer count the list
shows.

Switch the top summary to derive from the same deduped peerAgents array
the list uses:
- `{connectedPeers.length} peers` instead of `{connections.total} peers`
- `{directCount} direct / {relayedCount} relayed` computed from
  per-peer `connectionTransport ?? 'direct'` instead of libp2p totals

NetworkTab no longer takes the `connections` prop; the parent call site
drops it too.

Trade-off: we lose the raw libp2p multi-transport visibility from the
header (a peer with two connections now shows up once with whichever
transport its most-recent record reported). Acceptable for a user-facing
view — the consistency between summary and list matters more than the
fidelity of the raw libp2p count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): NetworkTab — prefer direct over relayed when deduping a peer

When the same peerId shows up under two transports (one direct, one
relayed), the previous "most-recent record wins" tie-break would
arbitrarily class the peer as direct or relayed based on polling timing.

Tie-break rule now, in order:
  1. Connected beats disconnected.
  2. Direct beats relayed (a peer with any direct connection is reported
     as direct — direct is the better transport, relayed is the fallback).
  3. Within the same transport class, most-recently-seen wins.

End result: `direct + relayed = total unique connected peers`. A peer
that's reachable on both transports counts once, under "direct", which
matches how a user would describe that peer's state ("I can talk to it
directly, falling back to relay if needed").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): floating "scroll to latest" pill in the messages region

When a user scrolls up to read earlier conversation, getting back to the
latest message required dragging the scroll thumb all the way down by
hand. Adds a Claude-Desktop-style floating pill that surfaces whenever
the scroll position drifts more than ~40px above the bottom.

Implementation
- New `messagesRegionRef` + `onScroll` handler on the messages container.
  Threshold is 40px to avoid flicker at the exact bottom edge.
- `showScrollToBottom` boolean toggles a `.visible` class on the pill.
  The pill is always mounted (`opacity: 0` baseline, `pointer-events: none`,
  `tabIndex={-1}`, `aria-hidden={true}` when hidden) and fades in over
  150ms when active. Reduced-motion users get an instant snap.
- `scrollMessagesToBottom` calls `messagesRegionRef.current.scrollTo({
  top: scrollHeight, behavior: 'smooth' })`. The existing
  `localChatEndRef.scrollIntoView` auto-snap on new messages keeps
  working — once the user is back at the bottom the pill fades out.

Visual
- 32×32 circle, `--bg-elevated` background, `--border-default` 1px outline,
  soft shadow. Down arrow icon (lucide `ArrowDown size={14}`).
- `position: sticky; bottom: 8px; align-self: flex-end` inside the flex
  column scroll container — the pill rides the bottom-right of the
  visible viewport with zero positioning math. Negative `margin-top`
  keeps it from claiming vertical space in the message flow when hidden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): center the scroll-to-latest pill, lift it above the composer

User feedback on the iteration-1 pill placement:
- Bottom-right alignment looked off-balance against the composer below.
- 8px gap to the composer's top edge was too tight.

Swap `align-self: flex-end` for `align-self: center` so the pill sits
in the horizontal middle of the messages region. Bump `bottom` from 8px
to 16px for cleaner breathing room above the composer shell. Pill stays
a 32×32 circle with the same fade / sticky behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chat(node-ui): force scroll-to-latest button square (lock to 32x32 circle)

Inside `.v10-local-agent-messages` (flex column, default align-items:
stretch) the cross-axis sizing was widening the button into an oval
even with width: 32px declared, because align-self: center on a sticky
flex child can drift the computed width. Pin every dimension so it
stays a circle:
- min/max-width and min/max-height = 32px
- flex: none (no flex-derived sizing)
- aspect-ratio: 1 (final safety)
- padding: 0 (defensively explicit)

No behavior change — same fade-in, same sticky positioning, same icon
and color. Just guaranteed-circle now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR1 Codex review — clamp widths, clearable picker, real e2e checks

Addresses 4 findings from the Codex review on #503:

(#1, layout.ts:46) — Persisted widths are now clamped to the same bounds
the live drag handlers enforce (140-400 left, 200-500 right). A stale or
hand-edited `dkg-layout` entry like `{"leftWidth": 2000}` would have
reloaded the shell into an unusable state with no UI recovery; on load
we now coerce it back into the legal range. New unit test seeds an
out-of-range entry and asserts the clamp.

(#2, PanelRight.tsx:1163) — The custom Select replacement dropped the
implicit empty option the native `<select>` used to ship, leaving no UI
path back to "no project selected" once the user picked one. Prepended a
"No project (clear selection)" option that emits `''` so
handleSelectProject() can still clear the target.

(#3, agent-panel.spec.ts:184) — The sticky-header e2e set
`scrollTop = 9999` on a `.v10-agents-tab / .v10-agent-content` element
that became `overflow: hidden` in PR1 (the actual scrolling moved into
`.v10-local-agent-messages`). The assertion therefore passed vacuously.
Rewrote as a structural check: the mode-tabs element must be a flex
sibling (not a descendant) of the scrollable messages region, which is
the actual sticky-by-construction contract this PR introduces.

(#4, agent-panel.spec.ts:232) — The project-picker dark-mode test
asserted against a snapshot baseline `project-select-dark.png` that
wasn't committed, so CI would have failed on the missing image rather
than catching a regression. Replaced with a DOM/style check: the
trigger's computed `color` must not equal its computed
`background-color` — same intent (kill the white-on-white bug) without
the snapshot-asset dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR2 Codex review — split refuse-overlay copy, tighten autosize tests

Addresses 3 findings from the Codex review on #504:

(#6, PanelRight.tsx:1071) — `attachmentsEnabled` was false for three
different reasons (no project, in-flight send, or integration that doesn't
support attachments), but the refuse-state overlay always blamed the
missing project. Surface the actual rejection: introduce
`dropDisabledReason` ('unsupported' | 'noProject' | 'sending' | null) and
render matching title + hint copy for each so the user can take the right
recovery action.

(#7, composer-autosize.test.ts:131) — The previous assertion
`style.height === '' || style.height.length >= 0` was tautologically true
for any string and didn't verify autosize was wired. Mock
`react-textarea-autosize` at the module level to capture forwarded props
and assert `minRows: 1` + `maxRows: 8` directly. Survives happy-dom (no
layout) without depending on DOM attributes the real library doesn't
forward.

(#8, agent-panel.spec.ts:284) — The clamp assertion was tied to current
font-derived metrics (`grown * 6 + 16`), making it flaky on CI font
substitutions and not actually checking the PR contract. Reworked to
assert the behavior the PR is meant to enforce: heightAt(12 lines) ≈
heightAt(8 lines), with ±2px slack for sub-pixel rounding, plus
overflowY ∈ {auto, scroll}. The check now fails iff `maxRows={8}` itself
regresses, not when the line-height changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex review — img sanitization, theme via store, restore-on-failure, GFM alignment, summary copy

Addresses 7 findings from the Codex review on #505 (skipping #14 — markdown
on user-text — and the already-fixed #10 lockfile claim):

(#9, MarkdownMessage.tsx:25) — Default `react-markdown` keeps the `img`
renderer enabled, so `![pixel](https://attacker.example/x.png)` in agent
output would trigger an outbound request from the chat surface. Added
`disallowedElements: ['img']` + `unwrapDisallowed` so the alt text falls
through as plain prose and no request is ever made.

(#11, CodeBlock.tsx:59) — Theme was sampled from `document.body.classList`
during render, which races with App.tsx's `body.light` toggle effect: on
initial load with a saved light theme, code blocks could mount with
`github-dark` and stay wrong until something else re-rendered them.
Switched to `useLayoutStore((s) => s.theme)` — same source that drives
the body class, no race.

(#12, MarkdownMessage.tsx:53) — Custom `th`/`td` renderers dropped the
props react-markdown forwards for GFM column alignment (`style.textAlign`
derived from `:---|---:|:---:`), so aligned tables rendered all-left.
Spread `...props` through wrappers; also propagated through `thead`,
`tbody`, `tr` for consistency.

(#13, PanelRight.tsx:1940) — Optimistic-clearing attachment drafts before
`streamLocalAgentChat` resolved made failed sends destructive: the
composer chips were gone but the agent never received the files. Keep
the optimistic clear (UX win from earlier user feedback), but on `catch`
merge the `processedDrafts` back into the conversation's draft list so
the user can retry. Merge (not overwrite) preserves any new drafts the
user queued during the in-flight request.

(#15, MarkdownMessage.tsx:66) — Block-vs-inline detection for fenced
code used `text.includes('\n')` as a fallback signal, which missed
single-line fenced blocks with no language (` ```foo``` `) because
`extractCodeText` strips the trailing newline. Switched to checking the
HAST parent's `tagName === 'pre'` — react-markdown's structural signal
that this `<code>` was emitted from a fenced block, regardless of
content shape.

(#16, MarkdownMessage.tsx:39) — Forcing every link to `target="_blank"`
broke navigation for relative routes and hash anchors. Added
`isExternalHref` — only absolute `http(s)://` URLs get the
new-tab + `rel="noopener noreferrer"` treatment; relative and hash links
keep their normal in-app navigation semantics.

(#17, agent-panel.spec.ts:405) — Table test only asserted `.v10-md`,
which any markdown bubble would satisfy. Tightened to require the actual
table-rendering selectors (`.v10-md-table-scroll`, `table.v10-md-table`,
plus a `<th>` and a `<td>` with the wrapper classes). Skips gracefully
when no table-bearing bubble exists in the env, with a pointer to
`markdown-message.test.ts` for the deterministic coverage.

(#18, PanelRight.tsx:1429) — Top summary read "{N} peers" but the
section list shows BOTH connected and disconnected groups, so "0 peers"
felt wrong when the Disconnected section had thousands of entries.
Relabeled to "{N} connected" so the summary clearly speaks for the
Connected section alone.

Test harness:
- Tightened the G3 plaintext-fallback test to check the shiki-import
  COUNTER DELTA rather than absolute zero. The mocked factory is
  cached module-wide by vitest after the first import, so an earlier
  test in the file can legitimately push the counter to 1 — what we
  actually want to assert is that THIS render didn't trigger a new
  load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR1 Codex round-2 — portal-aware Select menu/option selectors

The custom <Select> renders its dropdown via createPortal to document.body
so the menu/option DOM lives OUTSIDE `.v10-local-agent-target-select`.
The descendant selectors `.v10-local-agent-target-select .v10-select-menu`
and `.v10-local-agent-target-select .v10-select-option` would never match,
so any e2e check using them would fail even when the picker works.

Switched both to top-level class selectors (`.v10-select-menu` /
`.v10-select-option`) so Playwright actually finds them when the picker
is open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR2 Codex round-2 — drop Paperclip-import assertion

This test asserted that the panel-right.tsx source contains the literal
string `Paperclip` (the lucide import name). Codex flagged this as too
implementation-coupled — swapping icon libraries or renaming the import
alias would break the test even when the user-visible composer still
worked.

Removed the assertion. The surrounding checks already cover the
user-visible contract (composer-attach className, "Attach files"
aria-label, composer-toolbar wrapper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-2 — hoist drafts, img placeholder, scroll-pill state sync

Addresses 3 new findings from Codex's second-round review on #505:

(#CEMy3, PanelRight.tsx:2037) — `deliveredAttachmentIds` and
`processedDrafts` were declared inside the `try` block, but the
restore-on-failure path in `catch` referenced them. That puts them
out of scope: TypeScript wouldn't compile and the restore could
never run at runtime. Hoisted both above the try (initialized to
empty arrays); assigned inside the try; the catch can now read them
safely.

(#CEMzA, MarkdownMessage.tsx:41) — `disallowedElements: ['img']` +
`unwrapDisallowed` drops `<img>` nodes entirely, INCLUDING their
alt text, because `<img>` has no children to unwrap. So a useful
agent reply like `![architecture diagram](https://...)` would
disappear from the chat surface. Switched to a `components.img`
override that renders an inert `.v10-md-image-placeholder` chip
labelled `[image: alt]` — no outbound request, alt text preserved,
visible context for the user. New styles.css rule for the chip.

(#CEMzF, PanelRight.tsx:843) — `showScrollToBottom` was only
recomputed from the scroll-handler, so switching to a shorter
conversation (or hydrating history into the current one) left the
pill stuck visible from the previous thread until the user scrolled.
Added an effect that re-measures on every change to
`selectedIntegrationId`, `selectedSessionId`, or `localMessages.length`,
deferred by `requestAnimationFrame` so React's commit + the
auto-scroll effect have time to land.

Test:
- The `prepareAttachmentDraftsForSend` assertion in openclaw-bridge.test.ts
  expected `const processedDrafts = await prepare...`, but the
  hoist drops the `const`. Loosened the assertion to match the
  new assignment-without-`const` shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR1 Codex round-3 — clamp at setter, skip disabled on Home/End, always-clearable picker

Three Codex round-3 review fixes on PR #503:

- CEcBz: clamp left/right widths inside setLeftWidth / setRightWidth, not
  only on load. Stops programmatic callers (and any future drag handler
  variant) from pushing the store outside the bounds loadPersisted
  enforces — the persisted blob now matches what the store accepts on
  reload.

- CEcBv: Home / End now skip disabled options, matching ArrowUp / Down.
  Previously End could land on a trailing disabled row (e.g. a
  Loading/separator marker), making Enter a silent no-op and leaving
  the user stuck on a dead highlight.

- CEcBq: the project picker is only disabled while the project list is
  loading. Even with zero real projects, the "No project (clear
  selection)" option is always present, so the user can always reopen
  the picker to clear a stale selection.

Tests updated to match new behavior; full suite (463 / 463) green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR2 Codex round-3 — gate plain Enter with sendability check

CEdrv: the send button gates on
`inputDisabled || (!localInput.trim() && !hasSendableAttachmentDrafts)`,
but the plain-Enter branch in the textarea onKeyDown handler called
`onSendLocalMessage()` unconditionally. Pressing Enter on an empty
composer (or while inputDisabled) would fire a no-op send even though
the visible send affordance was disabled — confusing, and a divergence
from the established keyboard contract.

Plain Enter now mirrors the same gate as the send button. Ctrl/Meta+
Enter already had this check.

Adds two coverage tests:
- plain Enter does NOT send when input is empty / whitespace and no
  attachments are queued
- plain Enter DOES send when text is empty but attachments are queued
  (matches the Ctrl+Enter behavior — attachments alone are a valid
  payload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR1 Codex round-4 — only inject "No project" clear option when needed

BN3mu: the picker's value is `activeProjectId ?? ''`. With the synthetic
`{ value: '', label: 'No project (clear selection)' }` always at the
top of the options list, the trigger label became "No project (clear
selection)" whenever no project was active — masking the
"Choose a project" / "Loading projects…" placeholder and looking like a
real selection.

Render the synthetic clear option only when `activeProjectId` is truthy:
the row exists exactly when there is something to clear, the empty
trigger value no longer matches any option in the cleared state, and the
placeholder surfaces correctly.

Picker disabled condition updated to match: disabled while loading OR
when nothing is active AND no real projects exist (no rows to pick).
When a stale active project is set, the clear row is always present, so
CEcBq's "user must always be able to clear a stale selection" rule
still holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR1 Codex round-5 — Select closes on disabled flip, click guard

BOMw6: option clicks ignored the component-level `disabled` state. If
the parent flipped `disabled` to `true` while the menu was open, the
trigger greyed out but the portal-rendered menu remained mounted and
interactive — users could still select stale options.

Two-pronged guard:

- A new effect closes the menu when `disabled` becomes true while open,
  bringing the menu state in line with the trigger's affordance.

- The option `onClick` re-reads `disabled` so a click that races the
  effect on the same tick is still a no-op (the close-on-disabled
  effect would otherwise need a re-render to take effect).

Coverage: a new test renders the Select unmocked, opens the menu, then
re-renders with `disabled: true` and asserts the portal-rendered menu
is unmounted and `onChange` was never called.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR2 Codex round-6 — restore mismatch-target warning + drop brittle source-text asserts

Two follow-up fixes on PR #504:

- BNiBq / BOay0 (Critical): the iteration-1 polish narrowed the queued-
  attachment hint to `attachmentTargetIds.length > 1`, which silently
  dropped the single-target mismatch case. If a user attached under
  project A, switched the picker to B, and sent, the drafts still
  routed to A but the composer no longer surfaced that. New
  `hasMismatchedAttachmentTargets` flag re-includes that case and the
  hint now picks the right message ("…stored target (A), not the active
  project." vs the multi-project line).

- BOay7 (Issue): openclaw-bridge.test.ts asserted source-code snippets
  (the picker `value` JSX, the queued-status conditional, the fallback
  key expression). That's brittle to harmless refactors without
  catching real regressions. Replaced with the observable contract:
  className / aria-label / user-visible copy. The actual rendering
  behavior is covered by composer-autosize.test.ts,
  attachment-chip.test.ts, panel-right.{component,logic}.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-6 — NetworkTab dedupe by agentUri, not peerId

BNlko (Critical): the NetworkTab peer-list dedupe collapsed agents by
`peerId`. A remote node that legitimately advertises multiple distinct
agents under the same peer would have all but one card silently
dropped — the dedupe rule was treating "two agents that share a peer"
as duplicates.

Switch the key to `agentUri` (a stable per-agent identifier), falling
back to `peerId` only when `agentUri` is missing so older feed records
still collapse instead of multiplying. The tie-break order
(connected > disconnected, direct > relayed, most-recent) is
unchanged — it still resolves multi-transport records of the same
agent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-7 — fenced-block detection + AgentTabMenu popover collision

Two follow-ups on PR #505:

- BOjOQ (Critical): block-vs-inline code detection lived in the `code`
  renderer and relied on `node.parent`, which react-markdown does not
  populate reliably. Unlabelled fenced blocks (```\nfoo\n```) leaked
  through as inline `<code>` chips. Moved detection into the `pre`
  renderer — `<pre>` always wraps a fenced block in markdown, so
  reading the inner `<code>` AST node directly (language class + raw
  text) is reliable regardless of whether the fence has a language
  tag. The `code` renderer is now strictly the inline path.
  Coverage: new test in markdown-message.test.ts asserts that
  ```\nfoo\n``` produces `.v10-md-pre` + `.v10-md-copy` and not
  `.v10-md-code`.

- BOjOc: the `.v10-agent-tab-menu-popover` was anchored `left: 0` to
  fix the left-edge clip, but that overflowed the panel for rightmost
  agent tabs on narrow layouts. Added a `useLayoutEffect` in
  AgentTabMenu that measures the popover after open and toggles a new
  `.align-right` class when the projected right edge would clip the
  panel's right edge. The class flips `left: auto; right: 0`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR2 Codex round-7 — drop overlay non-interactive + e2e refuse via UI

Two follow-ups on PR #504:

- CFQfw (Critical): `.v10-drop-overlay.active` flipped to
  `pointer-events: auto`, which can steal the follow-up
  `dragenter`/`dragleave`/`drop` sequence from the dropzone root in real
  browsers and cause flicker or dropped files failing to register.
  Removed the override — the base rule's `pointer-events: none` keeps
  the overlay decorative, and the dropzone root (which owns the drag
  lifecycle) is the only interactive surface.

- CFQf2: the `refusedWithoutProject` e2e test gated on
  `window.__dkgProjects`, a global that is never exposed in
  packages/node-ui — the assertion was effectively dead. Rewrote to
  clear the active project through the picker UI (open trigger →
  click "No project (clear selection)"), with clean skip paths for
  envs where there is no project to clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-8 — peer card React key + inert relative-link guard

Two follow-ups on PR #505:

- CFThe / CFXYP (Critical): the NetworkTab peer-card React key was
  still `peerId` even after BNlko switched the dedupe to
  `agentUri || peerId`. When a remote node legitimately advertises
  multiple distinct agents on the same peer, the deduper preserved
  them but React saw the same key for both rows and could reuse the
  wrong card across re-renders (stale data or a vanished entry).
  Key the cards with the same identity used for dedupe.

- CFThj: relative / `javascript:` / `data:` / `#fragment` hrefs from
  untrusted chat content rendered as live anchors, so an agent could
  emit `[click](/admin)` and silently navigate the user away inside
  the app. Replaced the binary external-vs-internal split with a
  three-way classify: `http(s)` → new-tab anchor, `mailto:` →
  same-tab anchor (system mail client handles it out-of-band),
  anything else → inert `<span class="v10-md-link-inert">` that
  surfaces the literal href via `title` but is not clickable.
  Coverage: three new tests in markdown-message.test.ts pin down
  mailto, relative-href inert rendering, and the broader inert
  bucket (`javascript:`, `data:`, fragment, relative path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR2 Codex round-8 — reuse dropDisabledReason for attach button

CFfZ3: the icon-only attach button had its own ad-hoc disabled gate
(`!selected?.chatAttachments || !activeProjectId || localSending`)
that duplicated — and could drift from — the dropzone's
`dropDisabledReason` state. Its tooltip also kept advertising the
generic "Attach files" affordance even when the button was disabled
for an unsupported-agent or in-flight-send reason, leaving the user
without recovery copy.

Switch the button to gate on `attachmentsEnabled` (derived from the
same shared `dropDisabledReason` chain) and pick a state-specific
tooltip — so the button and the drop overlay stay in lockstep and
the user sees the same recovery hint via either path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-9 — streaming scroll-pill + transport summary tooltip

Two follow-ups on PR #505:

- CFiJH (Critical): the scroll-to-latest pill effect only watched
  `localMessages.length`, which misses the streaming case where the
  last assistant message grows in place (text appended to the same
  array entry). A user hovering just above the bottom could be pushed
  past the 40px threshold by streamed content while the pill stayed
  hidden. Added a ResizeObserver on the messages region (and its
  inner content wrapper) so any height change — streamed chunk, image
  load, markdown re-render — re-evaluates the pill state.

- CFiJL: the "X direct / Y relayed" summary reflects *preferred
  transport per peer* (a peer reachable on both transports gets
  bucketed under `direct` by the dedupe rule), so "0 relayed" can
  appear even while relay paths are actively in use. Added an
  explanatory `title` so the readout doesn't read as "no relay paths
  in use" — for raw transport-channel diagnostics /api/connections
  remains the source of truth.

Note: CFiJC (lockfile claim) verified false alarm —
`pnpm install --frozen-lockfile` succeeds against current
pnpm-lock.yaml. The lockfile diff is already in PR3 (4863ae77).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-10 — NetworkTab connection fallback, stable streaming observer, shiki language list

Three follow-ups on PR #505:

- CF4AU (Critical): the NetworkTab summary and empty state read solely
  from the deduped /api/agents feed. In the common race where libp2p
  has connections up before /api/agents has emitted records (or the
  remote peer carries no agent metadata), the panel showed
  "0 connected / No network peers detected yet" even though the node
  was connected. Now: when peerAgents is empty but connections.total
  is non-zero, render a transitional "Connected to N peer(s) (agent
  metadata syncing…)" state, and light the connected dot. The
  established deduped-count semantic for the direct/relayed split is
  preserved when peerAgents is populated — this only patches the
  empty-state race.

- CF4AZ (Critical): the scroll-pill observer captured
  `messagesRegion.firstElementChild` once at mount and attached a
  ResizeObserver to it. If the tab first rendered a loader/empty state
  and then transitioned to a streaming conversation, the observer
  stayed pointed at the old node — in-place message growth no longer
  updated `showScrollToBottom`. Switched to a MutationObserver on the
  scroll container itself (`childList + subtree + characterData`),
  which is stable across content-root transitions and catches both
  new-message and stream-grew-in-place updates.

- CF4Ae: the shiki language allow-list omitted languages this
  monorepo uses regularly. Added solidity / rust / go / toml / diff /
  dockerfile / xml plus aliases (sol → solidity, rs → rust,
  golang → go, patch → diff, docker → dockerfile). Fenced blocks tagged
  with any of those now actually highlight instead of silently
  falling through to plaintext. New test pins down the alias map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-11 — gate markdown to assistant role only

The four previously-deferred Codex threads (CBnNU / CCyxn / CFNsU /
CFXYU) all flagged the same real problem: routing every chat bubble
through `MarkdownMessage` also reinterprets user-side content.

Two concrete bugs follow from that:

1. UX — typing `# heading` or `---` visibly transforms the bubble, so
   the transcript no longer matches the prompt the user sent.

2. Security — synthetic user-side text (attachment / import
   summaries) embeds raw filenames verbatim. A filename like
   `[spec](https://attacker.com)` rendered the synthetic summary as
   a clickable external anchor. The CFThj relative-link guard didn't
   help — those hrefs are absolute http(s) and live by design.

Fix: gate markdown rendering on `message.role`. Assistant output —
the only content that's actually authored as markdown — continues to
flow through `MarkdownMessage`. User bubbles render through a new
`.v10-chat-plaintext` span with `white-space: pre-wrap` so the user
sees the exact characters they sent (including newlines), and
agent-controllable filenames can't synthesize a live external link.

Adds a logic test that pins down the gate (user bubble has no `<a>`,
no `<h1>`, no `.v10-md-` markup; assistant bubble still renders
markdown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-12 — dedupe status tie-break + inline-code line-height selector

Two follow-ups on PR #505:

- CGaLH (Critical): the NetworkTab dedupe tie-break always preferred a
  `connected` record over a `disconnected` one for the same agent,
  even when the disconnected record was newer. If /api/agents briefly
  retained an older connected row after a peer dropped, the UI would
  stick that peer in the Connected section and skew the summary
  counts. Status disagreement now resolves on `lastSeen` — newer
  record wins, regardless of status. Same-timestamp ties still prefer
  the connected reading (less jitter on the first poll after
  reconnect). Same-status records still go through the existing
  transport-rank → most-recent fallback chain.

- CGaLL: the `.v10-md-p:has(> .v10-md-code)` selector used a child
  combinator, which only matches when the paragraph or list item
  consists solely of a `<code>` node. The common
  `text <code> text` case missed the rule and didn't get the taller
  line-height. Dropped the `>` so any descendant `<code>` triggers
  the breathing-room line-height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-13 — synthesized content, observer re-attach, dedupe feed-order tie-break

Three follow-ups on PR #505:

- CGpe9 (Critical): the role-only markdown gate had a remaining hole.
  Assistant-role bubbles can carry LOCALLY synthesized content
  (`mapHistoryMessage` falls back to `buildAttachmentSummary` when an
  agent text is missing; error / cancel strings synthesized locally),
  which embed raw filenames or error bodies. A filename like
  `[spec](https://attacker.example)` would render as a live anchor in
  an assistant-styled bubble. Added a `synthesized?: boolean` flag on
  `LocalAgentMessage` that the renderer treats as plain text — the
  three synthesis sites (history fallback, success-without-agent-text,
  error/cancel) all set it. Test: synthesized assistant content
  renders as `.v10-chat-plaintext` and produces no anchor pointing at
  the attacker URL.

- CGpfC (Critical): the MutationObserver effect for the scroll-pill
  ran once with `[]` deps. If the panel mounted in the loader / empty
  / add-flow state, `messagesRegionRef.current` was null and the
  effect returned early — the observer never attached after the chat
  shell appeared later. Switched to a callback ref pattern: a state
  variable mirrors the DOM element, the existing `useRef` consumers
  keep working, and the observer effect now re-runs on mount /
  unmount of the messages region.

- CGpfF: the dedupe status tie-break still preferred `connected` on
  `lastSeen` ties, including when both records had no timestamp at
  all. Feeds that omit timestamps could keep a stale connected row
  masking a fresher disconnect. Switched to feed-order freshness —
  `reduce` processes records left-to-right, so the later record
  (`peer`) is the freshest by definition; always take it on tie.
  No transport-rank discrimination on status disagreement — different
  statuses are about freshness, not transport quality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-14 — dedupe timestamp guard + drop synthetic `node` prop

Two follow-ups on PR #505:

- CG3Lw (Critical): the status tie-break still leaked stale rows when
  the newer record omitted `lastSeen`. `AgentInfo.lastSeen` is
  optional and round-13 defaulted missing values to `0`, so an older
  connected record with a timestamp would beat a newer disconnected
  record without one — and stay in the Connected section forever.
  Fall through to feed-order freshness (later record wins) whenever
  EITHER side lacks a timestamp. Only run the numeric `lastSeen`
  comparison when both sides have one. `reduce` processes records
  left-to-right, so `peer` is always the later record by construction.

- CG3L5: react-markdown passes a synthetic `node` (HAST node) prop
  into every component renderer. The `thead` / `tbody` / `tr` /
  `th` / `td` renderers spread `...props` onto the underlying DOM
  tag, which forwarded `node` and triggered React's unknown-DOM-
  attribute warning in dev / test builds whenever a markdown table
  rendered. Destructure `node` out before the spread. Added a
  coverage assertion on the existing table-rendering test that
  pins down `hasAttribute('node') === false` for all five
  table-family elements.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-15 — initial pill recompute on mount + drop dishonest e2e checks

Two follow-ups on PR #505:

- CHBiH: the MutationObserver effect for the scroll-pill re-attaches
  when `messagesRegionEl` becomes available, but it never ran an
  initial recompute. If the panel mounted in the add-flow / loader
  state and later transitioned to an already-populated chat, the
  pill stayed stuck on its previous `false` until the next mutation
  or manual scroll fired. Added a one-shot `recompute()` call at the
  top of the effect so the pill reflects the messages region's
  current scroll state the instant it appears.

- CHBiK: removed the four PR3 markdown e2e checks
  (`rendersTable`, `codeBlockCopy`, `linkOpensExternal`,
  `scriptSanitized`). They `test.skip()`-ed whenever daemon-seeded
  chat content was absent, which is the common CI case, so the
  suite advertised PR3 coverage it almost never delivered.
  Deterministic coverage already lives in:
    - test/markdown-message.test.ts (22 tests, real DOM via
      happy-dom: GFM, link rel/target, sanitization, lazy-shiki
      gate, fenced-block detection inc. unlabelled, inert
      relative hrefs, `node` prop guard)
    - test/code-block.test.ts       (10 tests: shiki rendering,
      plaintext fallback, copy + clipboard, language alias map)
  Left a placeholder comment pointing at a future fixture-route
  approach for re-adding real-browser e2e coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-16 — dedupe partial-timestamp guard + honest lazy-shiki gate + shiki-rendered regression

Three follow-ups on PR #505:

- CHMS1 (Critical): the status-disagreement tie-break fell through to
  feed-order whenever EITHER side lacked `lastSeen`. `/api/agents`
  doesn't sort by recency, so an upstream ordering change could
  silently flip the panel back to a stale state. New rule:
    * both timestamped → numeric freshness (peer wins ties)
    * only peer timestamped → take peer
    * only prev timestamped → keep prev
    * neither timestamped → prefer disconnected (a stale connected
      row can never mask a real disconnect from a timestamp-less feed)

- CHMS6: the `lazy-shiki gate` test asserted `shikiImportCount === 0`,
  but Vitest module-caches `shiki` across the file. Once an earlier
  fenced-block test imported it, subsequent `import('shiki')` calls
  resolved from cache without re-running the mock factory, so the
  gate could false-pass even if a real load happened. Added
  `vi.resetModules()` to `beforeEach` so every test starts with a
  fresh module graph; the counter reset is now genuine. Also dropped
  the G3-pascal test's "before/delta" arithmetic since it no longer
  needs to work around cross-test pollution.

- CHMTB: the CodeBlock suite asserted only the immediate plaintext
  fallback. A broken shiki bundle, a botched normalizeLang map, or a
  wrong theme key would still pass every existing test. Added a
  regression test that mocks shiki per-test, dynamically re-imports
  CodeBlock through a fresh module graph, awaits the
  `loadHighlighter().then(setHtml)` chain, and asserts
  `.v10-md-pre-rendered` actually replaces the fallback with the
  highlighter's output — pinning down that `code`, `lang`, and the
  dark-theme key all flow through to `codeToHtml`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR3 Codex round-17 — stop rewriting literal backslash-n in agent content

CHWpS (Critical): `normalizeMessageContent` rewrote literal
backslash-n (`\n`, two characters: `\` + `n`) into a real newline.
That was a legacy workaround for a transport that double-escaped its
strings before any markdown rendering existed.

With PR3's markdown + code-block rendering active, the rewrite now
silently corrupts legitimate agent output:

- JSON snippets:   `{"text":"a\nb"}` got split across two lines,
                   making the displayed JSON invalid.
- Shell samples:   `echo -e "a\nb"` lost its escape and broke the
                   visible command.
- Any code sample that intentionally includes an escaped newline.

Dropped the rewrite. CRLF → LF normalization and leading/trailing
blank-line trim stay — those are real DOM/rendering concerns and not
content corruption. If any specific transport ever needs unescaping,
the right place is the transport boundary, not the renderer.

Updated the existing normalize test to reflect the new contract
(real newlines still trim, CRLF still folds, embedded `\n` stays
literal) and added a regression test for the JSON / shell-snippet
cases that motivated the change.

Note: CHWpK (lockfile not updated) is a false alarm — pnpm-lock.yaml
already carries all four new deps (react-markdown, remark-breaks,
remark-gfm, shiki) and `pnpm install --frozen-lockfile` succeeds
locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jurij Skornik <jurij.skornik@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Jurij89 added a commit that referenced this pull request May 15, 2026
… states + timestamp expansion (#516)

* feat(node-ui): PR4 chat panel polish — full-width assistant + send-button states + timestamp expansion

Consolidation pass on the chat panel after the 3-PR revamp landed
(#503 / #504 / #505). Five distinct improvements rolled into one PR:

1. Drop the assistant bubble — full-width content.
   User messages stay as a right-aligned pill (unchanged). Assistant
   replies now render full-width without a background, border, or
   max-width constraint — matching Claude Desktop / ChatGPT / VS Code
   Copilot. The `.v10-chat-msg.assistant` wrapper switches to
   `align-items: stretch`; `.v10-chat-bubble.assistant` keeps only
   typography rhythm. As a side benefit the dark-mode contrast
   complaint disappears — assistant text inherits --text-primary on
   --bg-panel directly (~16.5:1 dark, ~13.8:1 light, well above
   WCAG AAA).

2. Send button state machine: idle / uploading / streaming.
   - Idle (default): ArrowUp icon, normal send semantics.
   - Uploading attachments: lucide `Loader2` spinner with a CSS spin
     animation; button is informational and disabled until upload
     settles. Honors `prefers-reduced-motion`.
   - Streaming an assistant reply: lucide `Square` (stop) icon; click
     re-binds to `onStopLocalStream` and aborts the in-flight
     AbortController. The existing `catch (err: any)` in
     `sendLocalMessage` already handles `err?.name === 'AbortError'`
     by setting the assistant bubble to "Request cancelled.", so a
     single `.abort()` is enough — no extra teardown.
   New `stopLocalStream` callback exposed from the host through a new
   `onStopLocalStream` prop on `ConnectedAgentsTab`.

3. Expand the inline timestamp format to include the date.
   `formatLocalTimestamp` now uses
   `toLocaleString({ dateStyle: 'medium', timeStyle: 'short' })` so
   each bubble reads e.g. "May 14, 2026, 10:05 PM" instead of just
   "10:05 PM". The two inline `new Date().toLocaleTimeString(...)`
   call-sites (user-send + assistant-complete) now route through the
   same helper for a single consistent format across history-loaded,
   live-sent, and stream-completed timestamps.

4. WCAG 1.4.11 (3:1 non-text) polish.
   - `.v10-attachment-chip-remove`: bumped from `--text-tertiary` to
     `--text-secondary` (~4.07:1 dark / ~7.1:1 light against the
     chip's --bg-elevated background). Hover still promotes to
     --text-primary.
   - `.v10-md-hr`: bumped from `--border-subtle` to
     `--border-prominent`. Subtle / default / strong all failed 3:1
     against --bg-panel in both themes; --border-prominent is the same
     chip-outline token already in use and clears 3:1 comfortably.

5. Tests.
   - `formatLocalTimestamp` test pins the new date+time output
     (locale-resilient — asserts year + colon/AM/PM rather than the
     exact string).
   - Two new send-button state tests: uploading mode shows the
     spinner SVG + "Uploading attachments" aria-label; streaming mode
     shows the stop button + "Stop reply" aria-label.
   - Bubble-removal test pins down that `.v10-chat-bubble.assistant`
     carries no inline background/border attributes.
   - 4 test fixtures gain `onStopLocalStream: noop` for the new prop.

Out of scope (deferred separately): Select typeahead, hover-only
timestamps. Industry-aligned no-bubble layout pattern referenced
from Claude Desktop / ChatGPT / VS Code Copilot.

* fix(node-ui): PR4 UX-lead round-2 — stop-button distinction, ARIA wording, time semantic

Three P1 fixes from UX-lead's review of PR #516:

- P1-B: distinguish the streaming Stop button from the idle Send
  button so a user typing a follow-up mid-stream doesn't reflexively
  click the same-looking surface and accidentally abort the reply.
  Switch the filled silhouette for an outlined-square treatment
  (`--bg-active` surface + `--border-prominent` outline) — same
  shape, different visual reading. Matches Claude / ChatGPT's
  stop-button pattern. `--border-prominent` against `--bg-active`
  clears WCAG 1.4.11 3:1 in both themes.

- P1-C: aria-label / title wording. WAI-ARIA APG: button labels
  describe the action (or its current unavailability), not narrate
  state. `"Uploading attachments"` reads as narration; the new
  `"Send message (attachments uploading)"` reads as role + reason —
  matches what screen readers expect.

- P1-A (minimum version): wrap the inline timestamp in `<time
  dateTime={tsRaw}>{ts}</time>` for screen-reader / machine-parseable
  semantics. Added a companion `toIsoTimestamp` helper and a new
  `tsRaw` field on `LocalAgentMessage`; the three timestamp-creation
  sites (history-load, user-send, assistant-complete) now write both
  `ts` (display) and `tsRaw` (ISO) so they always point at the same
  instant. The full "X minutes ago" + hover-only relative-time
  treatment is deferred per user direction — a separate PR will
  layer it on top of this semantic foundation.

Affected test updated: send-button uploading-state assertion now
pins down the new aria-label format ("Send message (attachments
uploading)"). 546 / 38 skipped, 0 failed.

* fix(node-ui): PR4 UI-lead audit — three contrast blockers + two polish items

UI-lead's WCAG sweep on PR #516 surfaced three blocking contrast
fails plus two pre-existing items that PR4 made more visible. All
five are single-property swaps — same recipe as the `.v10-md-hr`
fix that already landed.

**Blockers (all bubble-removal regressions)**

Root cause: PR4 dropped the assistant bubble, so markdown surfaces
now sit on `--bg-panel` directly. Three markdown containers were
styled against the previous `--bg-surface` bubble interior and used
`--border-subtle` / `--border-strong` — both fail WCAG 1.4.11 (3:1
non-text) against `--bg-panel` in either theme.

- Task #65 `.v10-md-pre` (code block) — outline `--border-subtle` →
  `--border-prominent`. The `--bg-elevated` fill is barely a lift
  over `--bg-panel` (~1.05-1.11:1), so the border is the only visual
  cue defining the block.

- Task #66 `.v10-md-table-scroll` (table container) — same swap.
  Outer border was 1.14:1 dark / 1.16:1 light against panel.

- Task #67 `.v10-md-blockquote` (left rail) — `--border-strong` →
  `--border-prominent`. The rail is the only visual cue; the 5%
  text-tertiary wash inside is imperceptible on panel. Strong was
  1.57:1 dark / 2.31:1 light; prominent clears 5.58 / 7.17.

**Pre-existing polish, surfaced by PR4**

- Task #68 `.v10-local-agent-msg-time` — `--text-tertiary` →
  `--text-secondary`. Tertiary on bg-panel clears only 2.31-2.66:1
  (fails AA). Pre-existing fail, but PR4's expanded "May 14, 2026,
  10:05 PM" format makes the strings more prominent so it's worth
  fixing now. Secondary clears 5.58 / 7.17.

- Task #69 `var(--panel-elevated)` (in-flight message attachment
  chip inline style at PanelRight.tsx:1326) — token is undefined;
  correct name is `--bg-elevated`. The silent fallback used to
  coincidentally land near `--bg-surface` and look right; with the
  bubble removed it now falls back to the panel and the chip blends
  into its parent. One-character fix.

All 546 unit tests still pass. No new tests added — these are
visual/CSS-only changes with the contrast math already verified by
UI-lead. PR4 contrast story: assistant text 16.4:1 dark / 16.2:1
light; non-text surfaces all ≥3:1 in both themes.

* fix(node-ui): PR4 Codex round-1 — per-conversation abort + unified canSend gate

Four critical Codex comments on PR #516, two root causes:

**Per-conversation abort controllers (CIV4a / CIcaM / CIlg0)**

Three independent reports flagged the same bug: `localAbortRef` was a
single global `useRef<AbortController | null>`, but `localSending` is
tracked per conversation. Concurrent streams or a quick switch between
conversations would silently overwrite the ref — clicking Stop in
conversation A could then abort conversation B's request (or no-op
if A's stream had finished and cleared the ref).

Replaced with `useRef<Map<string, AbortController>>` keyed by
`conversationKey`. Three call-sites updated:
- `sendLocalMessage` stores `controller` under its `conversationKey`.
- `finally` does a compare-and-delete so a late teardown from a
  prior request can't wipe a newer same-key entry on retry.
- `stopLocalStream` looks up the controller for
  `selectedConversationKey` and aborts only that one.

**Unified `canSend` gate (CIlgu)**

The button correctly disabled itself when any draft was `uploading`,
but the textarea Enter / Cmd+Enter handlers still consulted only the
original "inputDisabled / sendable drafts" gate. A user pressing Enter
mid-upload would race `prepareAttachmentDraftsForSend`, which treats
`uploading` drafts as sendable — either starting a second import for
the same file or pushing the turn before the first upload finished.

Added a single `canSend` flag computed from `inputDisabled +
!isUploadingAttachments + has-text-or-sendable-drafts`. Both the
button's `disabled` prop and the two Enter handlers consult it, so the
two surfaces stay in lockstep.

Coverage: two new composer-autosize tests pin Enter and Cmd+Enter both
becoming no-ops while a draft is `uploading`. 548 / 38 skipped / 0
failed.

* fix(node-ui): PR4 round-2 — un-escape literal \n on history-load (refresh regression)

Live-streamed agent text arrives with real `
` characters and renders
markdown correctly. The DKG-memory persistence path, though, encodes
those newlines as literal `\n` (backslash + the letter n). On panel
refresh / history reload the literal characters survived into the
React state and the markdown renderer treated the entire content as
one long paragraph — code fences didn't open, paragraphs ran together,
table separators stayed as `|---|`-as-text.

Root cause: PR3 round-17 (Codex CHWpS) removed the global
`replace(/\n/g, '\n')` from `normalizeMessageContent` because it
corrupted legitimate `\n` content in live-stream code samples (JSON
like `{"text":"a\nb"}`, shell like `echo -e "a\nb"`). That fix is
still right for live content. The mistake was extending it to history
content, where the persistence layer has already encoded the newlines.

Symmetric fix at the transport boundary — exactly the place Codex
itself recommended ("the right place is the transport boundary, not
the renderer"). New `unescapeNewlinesFromHistory` helper applied
only in `mapHistoryMessage`, leaving the live-stream path
(`sendLocalMessage`) untouched so CHWpS stays addressed for typing-
during-stream and other live transports.

Known tradeoff: a code sample that intentionally contains literal
`\n` AND was later persisted via history will get its escape
unwrapped on reload. Fixing it cleanly requires the persistence
layer to round-trip strings faithfully (emit raw UTF-8 with real
newlines instead of JSON-escaped). Worth a daemon-side follow-up;
meanwhile the markdown-broken-after-refresh regression was the worse
user-visible problem and is what this PR is meant to polish.

Test impact:
- `openclaw-bridge.test.ts`'s static-text assertion updated for the
  new `decodedText` variable name and the new helper call.
- All 548 tests still pass.

* fix(node-ui): PR4 Codex round-2 — narrow history newline-decode heuristic (CLWmd)

Round-1 of PR4 round-2 fixed the "markdown breaks after refresh"
regression by blanket-decoding `\n -> \n` in history-loaded text.
Codex CLWmd flagged the lossy side of that — same concern PR3
round-17 (CHWpS) raised about the original global rewrite: code /
JSON samples containing intentional literal `\n` got mangled.
Windows `\r\n` also half-decoded to `\r` + real newline.

Detection heuristic: persisted-and-escaped content has zero real
newlines (the persistence layer replaced them all). Live content
that round-tripped correctly keeps its real newlines. So:

- If `text` already contains ANY real `\n` or `\r`, treat the
  `\n` sequences as intentional literals and skip the decode.
- Otherwise decode `\r\n` first (to avoid the half-decode
  Codex flagged), then `\n`.

False-positive scope: a single-line agent message that intentionally
contains literal `\n` AND zero real newlines AND was persisted —
that combination still gets unwrapped. Rare enough to accept;
clean fix is daemon-side (persistence should round-trip strings
faithfully, emit raw UTF-8 with real newlines).

New test pins down 4 categories: persisted-escaped decodes, live
content with literals is preserved, CRLF decodes to LF without
half-decode, edge cases (undefined / empty / no-escapes / plain
multi-line). 549 tests passing.

* fix(node-ui): PR4 Codex round-3 — narrow history-decode to markdown-structure markers (CNGB8)

Codex CNGB8 flagged that round-2's "no real newlines anywhere"
heuristic still mangled single-line JSON examples that intentionally
contain literal `\n` — `{"text":"a\nb"}`, `echo -e "a\nb"`,
prompts discussing escape sequences.

Narrowed the decode further: now requires the text to also carry an
unambiguous markdown-structure marker AFTER a `\n`. Seven markers
checked:
  - `\n\n`          paragraph break
  - `\n#`            heading
  - `\n- ` / `\n* `       bullet list
  - `\n[0-9]+. `     ordered list
  - `\n` + ``` + `` ``     fenced code block
  - `\n|`            table row
  - `\n>`            blockquote

CRLF content (`\r\n` between paragraphs) is handled by
pre-collapsing `\r\n` → `\n` for the marker probe only — keeps
the regex set compact and lets both LF and CRLF payloads share one
detection path. The actual decode still runs `\r\n` first so we
don't leave a half-decoded `\r` + real newline (Codex CLWmd's
secondary concern).

Single-line JSON / code samples without markdown markers now survive
unchanged. The remaining false-negative — a persisted plain
"line1\nline2" two-line message — gets shown with its literal `\n`
visible. Rare enough to accept; the proper fix is daemon-side
(persistence should round-trip strings as raw UTF-8 with real
newlines), tracked separately.

Test impact: existing `unescapeNewlinesFromHistory` test expanded to
8 categories — persisted markdown (paragraph break, heading, bullet
list, ordered list, fenced code, table, blockquote) decodes; single-
line JSON / code literal preserves; live content with real newlines
untouched; CRLF folds to LF without half-decode; edge cases
(undefined / empty / no escapes / plain multi-line). 549 tests
passing locally.

* fix(node-ui): PR4 Codex round-4 — boundary-only decode + per-conversation abort regression test

Two Codex follow-ups on PR #516:

**CSI-f (Critical) — decode only at structural boundaries**

Round-3 still ran a blanket `replace(/\n/g, '\n')` once the
markdown-marker gate opened, which corrupted `\n` literals INSIDE
fenced code blocks. A persisted

  Here is JSON:\n```json\n{"text":"a\nb"}\n```

would reload with `a\nb` turned into `a` + real newline + `b`,
breaking the rendered JSON example.

Replaced the blanket replace with seven targeted boundary replaces —
each only fires when `\n` is immediately followed by a specific
markdown marker (`\n`, `#`, `- ` / `* `, `digit. `, ` ``` `, `|`,
`>`). CRLF variants paired with each LF variant. The `\n` between
alphanumerics inside a code sample matches no rule and stays
literal — exactly the inline-JSON case Codex flagged.

Tradeoff: multi-line code blocks whose internal lines were joined
with `\n` will now show literal `\n` between code lines (the
internal `\n`s have no marker after them). Faithful display,
strictly better than the corruption blanket-decode would produce.
The proper fix is daemon-side — persistence should round-trip
strings as raw UTF-8 with real newlines, not escape-encode them.

**CSI-j (Issue) — per-conversation abort regression test**

Round-1's fix to the `localAbortRef` race (CIV4a/CIcaM/CIlg0) added
per-conversation abort controllers but had no regression cover.
Added a focused unit test that pins down the four invariants the
`useRef<Map<conversationKey, AbortController>>` implementation must
hold:

  1. Two conversations can hold separate controllers concurrently.
  2. Aborting the selected conversation's controller does NOT affect
     the other's.
  3. The `finally` compare-and-delete cleanup removes only its OWN
     controller — a late teardown from a stale request can't wipe a
     newer same-key entry on retry.
  4. Cleanup on the OTHER conversation leaves the selected one
     untouched.

24 panel-right logic tests passing, all 120 affected node-ui tests
pass in isolation. (Full-suite vitest run shows parallel-worker
flakes in panel-right.component / attachment-chip / markdown-message
/ openclaw-bridge / panel-right.refresh-history — pre-existing
infrastructure flakes documented across the prior 17 rounds; all
pass when their files run in isolation.)

* revert(node-ui): PR4 round-5 — remove UI-side history newline decode

Codex CSqGa correctly caught a fourth false-positive (and counting):
the markdown-marker gate still rewrites legitimate literals that
happen to contain a marker-shaped escape sequence, e.g.
`{"pattern":"\n- item"}` or `The token is \n#`. After history
reload those messages would render with real newlines the sender
never sent.

This is the fourth round of catching corruption in this heuristic:
  - CLWmd (round-2): blanket decode mangled JSON / shell snippets
  - CNGB8 (round-3): "no real newlines anywhere" gate still
    mangled single-line JSON examples
  - CSI-f (round-4): "markdown-marker" gate did a blanket decode
    inside the gated branch, corrupting code-block-internal `\n`
  - CSqGa (round-5): even boundary-only decode rewrites literals
    that happen to contain a marker-shaped sequence

The fundamental issue is that the UI cannot reliably distinguish
"agent intended a literal `\n`" from "persistence encoded a
newline as `\n`" without a richer signal. Reverted the helper
entirely; documented the known issue + proper fix path
(persistence-side: round-trip raw UTF-8 with real newlines, or
carry an explicit "escaped" marker on encoded payloads) in a
comment on `mapHistoryMessage`.

User-visible: persisted markdown turns will continue to show
literal `\n` characters on history reload until the persistence
layer fix lands. That's strictly less broken than the corruption
the UI-side guess introduced.

CSqGe (Issue) — separately, the openclaw-bridge.test.ts source-text
assertion is restored to its pre-PR4 form (`content: message.text
|| buildAttachmentSummary(...)`). Codex's broader concern about
source-string pinning vs. observable bridge behavior is valid and
applies to several pre-existing assertions in that file; addressing
it cleanly is a separate test-refactor and is tracked but not part
of this revert.

Tests: 24 (logic) + 4 (component) + 77 (openclaw-bridge) + 22
(markdown) + 14 (composer) = 142 passing locally. The CSI-j
per-conversation abort regression test added in round-4 stays.

* fix(node-ui): PR4 round-6 — dark-mode plain-text contrast + markdown-after-refresh

Bug 1: global `p { color: var(--text-secondary) }` won over `.v10-md-p`
(no color) once PR4 removed the assistant bubble whose own
`--text-primary` had masked it. Add explicit `--text-primary` to
`.v10-md-p`/`.v10-md-li`/`.v10-md-td` (class specificity beats bare `p`).

Bug 2: chat text is persisted via `JSON.stringify` but read back through
`stripRdfLiteral`, which only strips the literal wrapper and leaves JSON
escapes intact — so reloaded markdown showed literal `\n`. Add
`decodeRdfStringLiteral`, the exact deterministic inverse (re-quote +
`JSON.parse`), scoped to the two chat-text read sites only. Shared
`stripRdfLiteral` is untouched (nested-JSON / scalar call sites). Adds
round-trip unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR4 r6 follow-up — re-dim blockquote body + combined round-trip fixture

ui-lead review of 6264e14: react-markdown renders blockquote body as
`<blockquote><p class="v10-md-p">`, so the new explicit
`.v10-md-p { color: var(--text-primary) }` won directly over the
`--text-secondary` the inner `<p>` previously inherited from
`.v10-md-blockquote`, defeating the intentional dim-quote affordance the
plan required preserving. Add a scoped `.v10-md-blockquote .v10-md-p/li`
re-dim rule (specificity 0,2,0 beats 0,1,0; clears AA 5.45:1 dark /
6.92:1 light) and correct the now-precise comment.

Add the ux/qa-lead-suggested fixture: one string holding both a real
newline and a literal backslash-n token, locking both halves of the
JSON.stringify inverse against any future heuristic regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(node-ui): PR4 r6 — qa-lead hardening cases for decodeRdfStringLiteral

Add the two belt-and-suspenders cases qa-lead suggested in review:
lone single backslash (`a\b`, distinct from the `\n` token) and an
astral/surrogate-pair char (emoji + 𝕏). Both are lossless by
construction since JSON.parse is the exact inverse of the write-side
JSON.stringify; they pin the behavior against future regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(node-ui): PR4 r6 — Codex round-6: decode stored-transition reply + delocalize timestamp test

🔴 chat-memory.ts: the stored-transition overwrite used wrapper-only
stripRdfLiteral, so a persisted (stored) assistant turn — the dominant
path on reload — re-broke markdown with literal `\n` despite the base
schema:text decode. The transition assistantReply is written via the
same JSON.stringify (opts.assistantReply quad), so decode it with
decodeRdfStringLiteral too. Adds a multi-line stored-transition
regression test.

🟡 panel-right.logic.test.ts: formatLocalTimestamp test hard-coded
en-US / Gregorian traits (/2026/, /:|AM|PM/) that fail under non-English
or non-Gregorian runtime locales. Pin the actual PR4 contract by
comparing against the same Intl options the helper uses (medium date +
short time), assert date-present via full-vs-time-only inequality, and
match the locale's own numeric year — all locale-agnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jurij Skornik <jurij.skornik@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@branarakic branarakic mentioned this pull request May 15, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant