12 KiB
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 CSSgroup-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/visibilityreport the button as visible even when the rail isopacity-0— checkopacityfor 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). idfollows the Radix patternradix-_r_<n>_— unstable across renders, do not match on id; match onaria-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"anddata-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-aligndescribe popper placement (bottom/endobserved).idis 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/itext 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.pysend 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 thediv[role="log"]level (not wrapped in its own element per message). - Menu header (§3a): relative format
Today at 3:48 AM. - There is no
datetimeattribute / ISO timestamp anywhere in the message DOM.
Author display
- Own messages:
<span class="sr-only">You:</span>— screen-reader-only, first child ofdiv.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
- Delete menuitem render conditions (seen on own message once, absent once).
- Rendered reaction badge markup (no badges present to inspect).
- Whether the empty
span.contentsafter quick reactions becomes a full emoji picker. aria-pressedflip verification after applying a reaction (not clicked in testing).