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

16 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).

Re-verified 2026-10-04 ~04:30 UTC on all three fleet nodes (muse/9410, pip/9420, opm/9440) against live main chats. Menu contents re-probed on muse (own + other's menus, "More reactions" dialog opened and Escape-closed, no residue). Corrections inline; no per-node structural differences.

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

Structural correction (verified 2026-10-04): .group/msg is inside div[data-message-id], not the top-level message element. Full chain:

div[data-message-id]            ← class "flex flex-col gap-2", the message identity hook
└── div.group/msg               ← interactive wrapper (this section's hook)
    ├── div (action rail, absolute inset-y-0 end-full)
    │   └── button[aria-label="More options"]
    └── div.hatch-chat-groupable-bubble
        ├── span.sr-only "You:"   (own messages only)
        └── p / div               (visible body)

div[data-message-id] additionally carries a direct-child span.sr-only with the full accessible label ("User message: <text>" / "Assistant message: <text>") — see DOM-MESSAGES.md §3.

Every rendered message has exactly one .group/msg, and every .group/msg has exactly one More options rail button (verified rails == groups on muse, pip, opm — including agent messages).

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 — DIFFERS BY OWNERSHIP (corrected 2026-10-04)

Verified on muse (CDP 9410): opening the menu on an own message vs an other's (agent) message yields different menus. The earlier doc described one menu; there are two.

Own message — children: header DIV, Reply, Copy, separator, Delete. No quick-reactions group at all.

[...menu.querySelectorAll('[role="menuitem"]')].map(mi => mi.innerText.trim())
// own message:   ["Reply", "Copy", "Delete"]

Other's message — children: header DIV, [role="group"][aria-label="Quick reactions"], separator, Reply, Copy. No Delete item.

// other's message: ["Reply", "Copy"]  (+ 7 reaction buttons in the group)
Own message menu Other's message menu
Timestamp header yes yes
Quick reactions absent present (7 buttons)
Reply / Copy yes yes
Delete yes (2/2 opens) absent
  • Delete was present on own messages in both opens this session; still absent on other's. The earlier "seen once, absent once" observation is superseded for the ownership axis, but keep a conditional mindset — render conditions beyond ownership are untested, and clicking Delete was not tested (destructive).
  • No Edit item exists on either menu. Message editing UI was not found anywhere (unchanged).

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, two variants observed: Today at 3:48 AM (today), Oct 3 at 8:01 PM (older). No <time> element, no datetime attribute.

b) Quick reactions — this IS the emoji picker entry point, and it appears only on other people's messages (own-message menus have no reaction UI).

<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">

Seven buttons (six emoji + a "More reactions" opener — the 7th was not in the earlier capture):

<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 🙏
More reactions (opens full picker — see below)
menu.querySelectorAll('button[aria-label^="React"]')   // 7 buttons, other's menu only
  • aria-pressed="false" flips to "true" once applied (state toggle).
  • Clicking "More reactions" opens a full emoji picker: [role="dialog"] with category headings (Smileys & People, …) — 346 React * buttons observed in the dialog. Escape closes it cleanly (verified, no draft left).

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 on all three nodes, 2026-10-04).
  • Message list level: dividers are direct children of div[role="log"], interspersed between messages (not per-message nodes):
    • Day boundary: div.pt-10.pb-4.text-center > span.text-caption-1.text-text-secondary — text Oct 3, 11:41 PM.
    • Intra-day gap: div.py-4.text-center > span.text-caption-1.text-text-secondary — text 2:38 AM (bare 12h time).
  • Menu header (§3a): relative format with two variants — Today at 3:48 AM (today), Oct 3 at 8:01 PM (older).
  • There is no datetime attribute / ISO timestamp anywhere in the message DOM.

Author display

Two sr-only layers (verified 2026-10-04):

  1. Wrapper level — direct child of div[data-message-id]: span.sr-only containing the full accessible label — "User message: <entire text>" / "Assistant message: <entire text>".
  2. Bubble level — own messages only: span.sr-only with exactly "You:", first child of div.hatch-chat-groupable-bubble. Agent messages have no bubble-level sr-only span.
// reliable own-vs-other discriminator (bubble level):
msg.querySelector('.group\\/msg span.sr-only')?.innerText.trim() === 'You:'
// full author+text label (wrapper level):
msg.querySelector(':scope > span.sr-only')?.innerText  // "Assistant message: …"
  • 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 (updated 2026-10-04)

  1. Delete menuitem render conditions — resolved on the ownership axis: present on own messages (2/2 opens), absent on other's. Untested whether other conditions (age, thread type) also gate it; clicking Delete untested.
  2. Rendered reaction badge markup (no badges present to inspect — 0 hits for [aria-label*="reacted"] / [data-testid*="reaction"] on all three nodes). Re-probe on a thread with known reactions.
  3. Full emoji picker — answered: "More reactions" button (other's menu only) opens [role="dialog"] with categories (Smileys & People, …), 346 React * buttons observed. Escape closes cleanly.
  4. aria-pressed flip verification after applying a reaction (not clicked in testing — applying a reaction is state-changing; left for a dedicated test).
  5. Menu open flakiness varies by node/moment (4/4 attempts failed on pip, first-try success on muse in the same session — possibly contention with parallel automation). Keep the retry loop (max 4, Escape between attempts).