# muse.ai Message DOM Map
> **Box is the main surface.** All operator work goes through Box (box.muse-dev.online). The web UI, `box` CLI, and agents share the same API endpoints. No UI-only powers.
Reference for automation against the muse.ai chat UI. Captured live via CDP
on the `opm` headless browser (viewport 780×493), 2026-10-04 ~03:45 UTC.
Verified states: main chat (`https://muse.ai/`) and empty new thread
(`https://muse.ai/thread/new`).
**Re-verified 2026-10-04 ~04:30 UTC** on all three fleet nodes (muse/CDP 9410,
pip/9420, opm/9440) against live main chats with real message history.
No per-node structural differences found. Corrections and additions from
that pass are inline below.
Related maps: sidechat panel selectors live in the sidechat work
(`hatch-chat-switcher-trigger`, `hatch-chat-compose`); this doc covers the
message composer, send button, message list, and chat activation.
---
## 1. Message input area
### Primary selector
```js
document.querySelector('textarea[aria-label="Message"]')
```
Fallback chain used by `muse-chat-api.py` (any one may match depending on render):
```js
document.querySelector('[contenteditable="true"]')
|| document.querySelector('textarea[placeholder*="Message"]')
|| document.querySelector('div[role="textbox"]')
```
### Observed attributes (main chat)
| Attribute | Value |
|---------------|--------------------------------------------------------------|
| tag | `TEXTAREA` |
| `aria-label` | `Message` |
| `placeholder` | `Message` |
| `rows` | `1` |
| `disabled` | `false` |
| `readOnly` | `false` |
| class | `text-text-primary block max-h-48 w-full resize-none bg-transparent` |
| visible | `offsetParent !== null` → `true` |
### Parent chain (4 levels)
```
textarea[aria-label="Message"]
└── div.relative.flex.min-h-8.min-w-0.items-center.ps-10 (also contains an and a
sibling)
└── div.flex.shrink-0.flex-col.px-3.py-3
└── div[role="button"].backdrop-blur-elevation-01.bg-fill-blur-thick.shadow-blur-elevation-01.rounded-32 ← composer "pill"
└── div.min-w-0.flex-1
```
### Ready-for-typing detection
```js
const ta = document.querySelector('textarea[aria-label="Message"]');
const ready = !!ta && ta.offsetParent !== null && !ta.disabled && !ta.readOnly;
```
Empty-state signal: the placeholder overlay is visible when nothing is typed:
```js
document.querySelector('[data-testid="hatch-composer-placeholder-overlay"]')
// → innerText "Message", visible when input is empty
```
### Typing into it (React-safe)
`execCommand('insertText')` is what the current tooling uses:
```js
const input = document.querySelector('textarea[aria-label="Message"]');
input.focus();
document.execCommand('insertText', false, message);
```
Alternative when React ignores execCommand — native setter + input event:
```js
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(ta, text);
ta.dispatchEvent(new Event('input', { bubbles: true }));
```
Adjacent composer buttons (same pill, not the send button):
| `aria-label` | Purpose |
|---------------------|----------------|
| `Attach file` | file upload |
| `Dictate a message` | voice input |
---
## 2. Send button
### Selector
```js
[...document.querySelectorAll('button')]
.find(b => b.getAttribute('aria-label')?.toLowerCase().includes('send'))
// observed aria-label is exactly "Send"
```
- Icon: inline SVG, arrow-up glyph (path data starts `M11.45 2.32`).
- No `data-testid`, no `title`, no innerText — match on `aria-label` only.
### Enabled / disabled states
- Observed `disabled === false` in the DOM even with an empty composer; the
button is **rendered once text is typed** and may persist afterwards.
- Do not rely on `disabled` alone. Practical send flow (as implemented):
```js
const send = [...document.querySelectorAll('button')]
.find(b => b.getAttribute('aria-label')?.toLowerCase().includes('send'));
if (send) { send.click(); /* 'sent' */ }
else {
// fallback: Enter key on the input
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));
}
```
### While the assistant is generating
The send button is replaced by the stop button:
```js
document.querySelector('[data-testid="hatch-composer-stop-button"]')
// present only while a response is streaming; absent when idle
```
---
## 3. Message list
### Scroll container
```js
document.getElementById('hatch-chat-scroll')
```
| Attribute | Value |
|-----------|--------------------------------------------------------------------|
| tag | `DIV` |
| id | `hatch-chat-scroll` |
| class | `flex flex-col overflow-hidden relative z-0 grow-1 shrink-1 min-h-0` |
### List element
```js
document.querySelector('div[role="log"]')
```
| Attribute | Value |
|--------------|----------------------------------------------------|
| tag | `DIV` |
| `role` | `log` |
| `aria-label` | `Chat messages` |
| class | `mx-auto w-full max-w-3xl min-w-0 @container flex` |
Parent chain of a message (bottom-up):
```
div[data-message-id] ← one per message, class "flex flex-col gap-2"
└── div[role="log"][aria-label="Chat messages"]
└── div
└── div
└── div
└── div#hatch-chat-scroll
```
### Individual message
Selector for all messages:
```js
document.querySelectorAll('div[data-message-id]')
```
**Structural correction (verified 2026-10-04, pip/muse/opm):** the interactive
`.group/msg` wrapper is *inside* `div[data-message-id]`, not a sibling
rendering. True per-message structure:
```
div[data-message-id] ← class "flex flex-col gap-2", one per message
├── div.group/msg ← interactive bubble + hover action rail
│ ├── 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, bubble-level)
│ └── p / div (visible body)
└── span.sr-only (direct child of the wrapper)
"User message: " / "Assistant message: "
```
The direct-child `span.sr-only` carries the **full accessible label** (author
prefix + entire message text, observed up to 2000+ chars). Own messages therefore
have *two* sr-only spans: `"User message: …"` on the wrapper and `"You:"` in the
bubble. Agent messages have only the wrapper-level `"Assistant message: …"` span.
There are still no `` / `