Files
box/docs/DOM-MESSAGE-ACTIONS.md
T

12 KiB
Raw Blame History

DOM Reference: Message Actions & Thread Interactions

Inspected live via CDP on the opm browser (warp-opm netns, CDP 9440), 2026-10-04. Covers main chat (https://muse.ai/) and sidechat threads (/thread/<uuid>). Read-mostly probes; the only state-changing probes were opening/closing the "More options" menu and one Reply-draft open/cancel cycle (composer verified clean after).

Related: DOM-CHAT-PANEL.md (panel/sidechat create), DOM-MESSAGES.md, DOM-PAGE-STRUCTURE.md.


1. Message wrapper

Messages carry no data-testid. The structural hook is the Tailwind group class:

<div class="group/msg ms-auto flex w-fit flex-col items-end
            [--hatch-message-narrow-max-width:78%]
            [--hatch-message-compact-action-rail-width:2.75rem]">

Selector:

document.querySelectorAll('.group\\/msg')   // note: escaped slash in class name

Parent chain (own message, bottom-up):

DIV.group/msg  (ms-auto, items-end → right-aligned = own message)
└── DIV.touch:select-none.flex.w-full.flex-col.items-end
    └── DIV.relative.flex.w-full.flex-col.items-end
        ├── DIV.<action rail>            (absolute inset-y-0 end-full …)
        └── DIV.relative.max-w-full
            └── DIV.relative.max-w-full
                └── DIV.hatch-chat-groupable-bubble
                    ├── SPAN.sr-only "You:"        (author label, a11y only)
                    └── P.text-[16px].leading-[22px].whitespace-pre-wrap  (body)

Message list container:

document.querySelector('div[role="log"][aria-label="Chat messages"]')

Bubble variants

Variant Bubble classes Alignment
Own (user) message hatch-chat-groupable-bubble … bg-chat-user-bubble text-chat-user-text group/msg has ms-auto items-end (right)
Agent message hatch-chat-groupable-bubble … bg-[var(--hatch-agent-bubble-bg)] left-aligned
Agent body text DIV.!text-[15px].text-text-primary (prose container) —

2. Action rail (hover-revealed)

Each div.group/msg contains exactly one action-rail div:

// selector: the rail inside a message
msg.querySelector(':scope > div > div > div[class*="end-full"]')

Full classes (desktop):

touch:static touch:mb-0 touch:h-auto touch:scale-100 touch:opacity-100
absolute inset-y-0 end-full my-auto flex h-fit shrink-0 scale-95 opacity-0
transition-[opacity,scale]
group-hover/msg:scale-100 group-hover/msg:opacity-100

Key facts:

  • Positioned absolute inset-y-0 end-full — vertically centered on the message's outer (end) side, my-auto.
  • Hidden with opacity-0, revealed purely by CSS group-hover/msg — no JS hover needed for automation; query the button directly.
  • touch: variants force it visible on touch devices.
  • Width driven by --hatch-message-compact-action-rail-width: 2.75rem.
  • ⚠️ getComputedStyle().display/visibility report the button as visible even when the rail is opacity-0 — check opacity for true on-screen visibility.

The single action button: "More options"

Every message (own AND agent) exposes exactly one action button — a Radix dropdown trigger:

<button aria-label="More options" id="radix-_r_n_"
        class="inline-flex shrink-0 cursor-pointer items-center
               justify-center rounded-full">
  <!-- SVG dots icon, no text -->
</button>
msg.querySelector('button[aria-label="More options"]')
  • 28×28 px, rounded-full, icon-only (SVG, no text content).
  • id follows the Radix pattern radix-_r_<n>_ — unstable across renders, do not match on id; match on aria-label="More options".
  • This button is the gateway to ALL message actions (reply, react, copy, delete). There are no separate always-visible reply/react/copy buttons.

3. "More options" menu (Radix dropdown)

Opening it (headless quirk)

A plain .click() did not open the menu. The full pointer sequence was required:

const b = msg.querySelector('button[aria-label="More options"]');
b.scrollIntoView({block: 'center'});
const r = b.getBoundingClientRect();
const opts = {bubbles: true, cancelable: true,
              clientX: r.x + r.width/2, clientY: r.y + r.height/2, button: 0};
b.dispatchEvent(new PointerEvent('pointerdown', opts));
b.dispatchEvent(new MouseEvent('mousedown', opts));
b.dispatchEvent(new PointerEvent('pointerup', opts));
b.dispatchEvent(new MouseEvent('mouseup', opts));
b.dispatchEvent(new MouseEvent('click', opts));
  • When open, the trigger gets aria-expanded="true" and data-state="open".
  • Opening was flaky in headless (~1 in 4 attempts needed a retry): loop up to 4×, checking for [role="menu"][data-state="open"], Escape between attempts.
  • Close with: document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', keyCode: 27, bubbles: true})).

Menu container

<div role="menu" aria-orientation="vertical" data-state="open"
     data-side="bottom" data-align="end"
     data-radix-menu-content="" data-slot="dropdown-menu-content"
     id="radix-_r_nd_" aria-labelledby="radix-_r_nc_" tabindex="-1"
     class="bg-fill-blur-thick backdrop-blur-elevation-03 text-text-primary
            z-50 min-w-56 rounded-16 p-1.5 …">
[...document.querySelectorAll('[role="menu"][data-state="open"]')]
  .find(e => /Reply/.test(e.innerText || ''))
  • data-side / data-align describe popper placement (bottom/end observed).
  • id is Radix-generated (radix-_r_nd_) — unstable, don't match on it.

Menu contents (in order)

a) Timestamp header

<div class="text-caption-2 text-text-secondary px-2 pt-1.5 pb-2">Today at 3:48 AM</div>

Relative format ("Today at …"). No <time> element, no datetime attribute.

b) Quick reactions — this IS the emoji picker (no separate picker UI exists)

<div role="group" aria-label="Quick reactions"
     class="flex flex-wrap items-center justify-between gap-0.5 px-1 pt-0.5 pb-1">

Six buttons, each:

<button type="button" aria-pressed="false" aria-label="React with thumbs up"
        class="flex size-7 shrink-0 cursor-pointer items-center justify-center
               rounded-full text-[20px] transition-transform select-none hover:scale-125">👍</button>
aria-label Emoji
React with thumbs up 👍
React with heart ❤️
React with laughing face 😂
React with surprised face 😮
React with crying face 😢
React with folded hands 🙏
menu.querySelectorAll('button[aria-label^="React"]')   // 6 buttons
  • aria-pressed="false" flips to "true" once applied (state toggle).
  • Followed by an empty <span class="contents"> — likely a conditional slot (e.g. "more emojis" expander); empty in all observed renders.

c) Separator

<div role="separator" class="bg-fill-divider mx-2 my-1.5 h-[0.5px]"></div>

d) Action items

<div role="menuitem"
     class="focus-visible:outline-none focus:bg-fill-secondary-elevated
            hover:bg-fill-secondary-elevated relative flex cursor-pointer">
  <div class="m-0.5 flex w-5 shrink-0 items-center justify-center">
    <!-- SVG icon -->
  </div>
  <div class="flex flex-1 flex-col gap-0 px-1 py-0.5">Reply</div>
</div>
[...menu.querySelectorAll('[role="menuitem"]')]
  .map(mi => mi.innerText.trim())
// own message:    ["Reply", "Copy", "Delete*"]
// other's message: ["Reply", "Copy"]
  • Structure per item: icon slot (div.m-0.5.flex.w-5… with SVG) + label (div.flex.flex-1… with the text).
  • Reply and Copy always present.
  • Delete observed on own messages in one render but absent in another — treat as conditional (possibly recency/ownership-gated). Clicking it was not tested (destructive).
  • No Edit item exists. Message editing UI was not found anywhere.

4. Reply UI (threaded replies)

Clicking the Reply menuitem (same full pointer sequence as §3) produces a reply draft bar above the composer — replies are quote-style, not inline threads:

<div class="bg-fill-secondary-elevated rounded-18 mb-3 flex w-full
            flex-col px-3 pt-2 pb-2.5">
  <div class="flex min-w-0 flex-1 flex-col">
    <div class="flex h-5 w-full items-center gap-0.5">
      Replying to operator-main
    </div>
    …
  </div>
</div>

Parent chain:

DIV.overflow-clip
└── DIV.origin-top
    └── DIV.bg-fill-secondary-elevated.rounded-18.mb-3…   ← reply bar (no data-testid)
        └── DIV.flex.min-w-0.flex-1.flex-col
            └── DIV.flex.h-5.w-full.items-center.gap-0.5  ← "Replying to <name>"

Facts:

  • The bar has no data-testid — detect via /^Replying to/i text match.
  • Text is Replying to <display name> (e.g. Replying to operator-main).
  • ⚠️ Escape does NOT reliably cancel the draft. In one test the bar survived an Escape keydown and was only gone on a later re-check. Automation that opens Reply must verify cancellation via the text check, and retry (or click the bar's close control) — otherwise the next dm.py send may go out as a reply.
  • No inline threaded-reply display structure was observed (no replies exist in the inspected threads); the model appears to be composer-quote only.

5. Timestamps & author display

Timestamps

  • No <time> elements exist in the inspected threads (zero hits).
  • Message list level: relative plain text, e.g. 3:47 AM, rendered as a text node at the div[role="log"] level (not wrapped in its own element per message).
  • Menu header (§3a): relative format Today at 3:48 AM.
  • There is no datetime attribute / ISO timestamp anywhere in the message DOM.

Author display

  • Own messages: <span class="sr-only">You:</span> — screen-reader-only, first child of div.hatch-chat-groupable-bubble. Not visually rendered.
// reliable own-vs-other discriminator:
msg.querySelector('span.sr-only')?.innerText.trim() === 'You:'
  • Visually, authorship is conveyed by bubble alignment/color only (right + bg-chat-user-bubble = own; left + agent bubble bg = other).
  • No visible author name label exists on either message type in the inspected UI.

6. Rendered reactions

Searched all messages in main chat for reaction badges:

document.querySelectorAll('[aria-label*="reacted"], [data-testid*="reaction"]')
// → 0 hits

No rendered reaction badges were found. Either no inspected message has reactions, or badges use different markup. Unresolved — re-probe on a thread with known reactions.


7. Automation recipes

List messages with authorship:

[...document.querySelectorAll('.group\\/msg')].map(m => ({
  own: m.querySelector('span.sr-only')?.innerText.trim() === 'You:',
  text: m.innerText.slice(0, 80)
}))

Open a message's action menu (headless-safe):

function openMsgMenu(msg) {
  const b = msg.querySelector('button[aria-label="More options"]');
  b.scrollIntoView({block: 'center'});
  const r = b.getBoundingClientRect();
  const o = {bubbles: true, cancelable: true,
             clientX: r.x + r.width/2, clientY: r.y + r.height/2, button: 0};
  ['pointerdown','mousedown','pointerup','mouseup','click'].forEach(t =>
    b.dispatchEvent(new ((t[0]==='p') ? PointerEvent : MouseEvent)(t, o)));
}
// retry loop: check [role="menu"][data-state="open"], Escape, retry (max 4)

Click a quick reaction:

menu.querySelector('button[aria-label="React with heart"]').click()
// (plain .click() sufficed for menu-internal buttons in testing)

Detect + clear a reply draft:

const hasDraft = /Replying to/.test(document.body.innerText);
// Escape, then re-check; do not assume it cleared

8. Open questions

  1. Delete menuitem render conditions (seen on own message once, absent once).
  2. Rendered reaction badge markup (no badges present to inspect).
  3. Whether the empty span.contents after quick reactions becomes a full emoji picker.
  4. aria-pressed flip verification after applying a reaction (not clicked in testing).