DOM reference: message actions and thread interactions\n\n- Messages use .group/msg (no testid); action via More options radix menu\n- 6 quick reactions documented; Reply is composer-quote only, no Edit UI\n- Headless quirk: full pointer event sequence needed for radix
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
# 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:
|
||||
|
||||
```html
|
||||
<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:
|
||||
|
||||
```javascript
|
||||
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:
|
||||
|
||||
```javascript
|
||||
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:
|
||||
|
||||
```javascript
|
||||
// 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:
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
```javascript
|
||||
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:
|
||||
|
||||
```javascript
|
||||
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
|
||||
|
||||
```html
|
||||
<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 …">
|
||||
```
|
||||
|
||||
```javascript
|
||||
[...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**
|
||||
|
||||
```html
|
||||
<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)
|
||||
|
||||
```html
|
||||
<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:
|
||||
|
||||
```html
|
||||
<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` | 🙏 |
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```html
|
||||
<div role="separator" class="bg-fill-divider mx-2 my-1.5 h-[0.5px]"></div>
|
||||
```
|
||||
|
||||
**d) Action items**
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
```javascript
|
||||
[...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:
|
||||
|
||||
```html
|
||||
<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.
|
||||
|
||||
```javascript
|
||||
// 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:
|
||||
|
||||
```javascript
|
||||
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:**
|
||||
|
||||
```javascript
|
||||
[...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):**
|
||||
|
||||
```javascript
|
||||
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:**
|
||||
|
||||
```javascript
|
||||
menu.querySelector('button[aria-label="React with heart"]').click()
|
||||
// (plain .click() sufficed for menu-internal buttons in testing)
|
||||
```
|
||||
|
||||
**Detect + clear a reply draft:**
|
||||
|
||||
```javascript
|
||||
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).
|
||||
Reference in New Issue
Block a user