Session: sidechat/dom-messages
16 KiB
muse.ai Message DOM Map
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
document.querySelector('textarea[aria-label="Message"]')
Fallback chain used by muse-chat-api.py (any one may match depending on render):
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 <input> and a <div> 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
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:
document.querySelector('[data-testid="hatch-composer-placeholder-overlay"]')
// → <span> innerText "Message", visible when input is empty
Typing into it (React-safe)
execCommand('insertText') is what the current tooling uses:
const input = document.querySelector('textarea[aria-label="Message"]');
input.focus();
document.execCommand('insertText', false, message);
Alternative when React ignores execCommand — native setter + input event:
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
[...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, notitle, no innerText — match onaria-labelonly.
Enabled / disabled states
- Observed
disabled === falsein the DOM even with an empty composer; the button is rendered once text is typed and may persist afterwards. - Do not rely on
disabledalone. Practical send flow (as implemented):
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:
document.querySelector('[data-testid="hatch-composer-stop-button"]')
// present only while a response is streaming; absent when idle
3. Message list
Scroll container
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
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:
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: <full text>" / "Assistant message: <full text>"
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 <article> / <time> / data-testid-containing-"message"
elements, and no visible author name on either message type.
Author is encoded in the id prefix:
| Author | data-message-id format |
innerText prefix |
|---|---|---|
| assistant | assistant-msg-<uuid> |
Assistant message: ... |
| user | <uuid> (bare, no prefix) |
User message: ... |
The User message: ... prefix is observed (verified 2026-10-04 on pip's
main chat); id-prefix detection remains the most robust signal.
⚠️ data-message-id is not messages-only: non-message cards share the
attribute, e.g. data-message-id="presentation-carrier:8917…" (media/
presentation card, empty innerText). For real messages filter to
/^[0-9a-f-]{36}$/ (user) or /^assistant-msg-/ (assistant).
Extracting text / author
const msgs = [...document.querySelectorAll('[data-message-id]')].map(m => {
const id = m.getAttribute('data-message-id');
return {
id,
author: id.startsWith('assistant-msg') ? 'assistant' : 'user',
text: m.innerText
};
});
Simple read-back (what muse-chat-api.py messages does)
[...document.querySelectorAll('p')].slice(-n * 2).map(p => p.innerText.slice(0, width))
Works because message bodies render as <p> elements; the last 2n
paragraphs cover roughly the last n messages. Crude but effective for
"did my text land?" verification.
Virtualization (verified 2026-10-04)
The message list is virtualized: only viewport-near messages exist in the
DOM. Observed [data-message-id] counts on live main chats: 5, 33, 40, 110
across nodes and moments on the same threads. Consequences for automation:
- DOM readback is windowed — you only see what's rendered. Scroll
#hatch-chat-scrollto load older messages; re-query after scrolling. - Never assert "message absent" from a single DOM snapshot; assert "not in rendered window".
.group/msgcount always equals the rendered message count (every rendered message gets the interactive wrapper + itsMore optionsrail button — verified rails == groups on all three nodes).
Date / time dividers
div[role="log"] children are not all messages. Date and time dividers are
direct children of the log, interspersed between div[data-message-id]:
<!-- day boundary -->
<div class="pt-10 pb-4 text-center">
<span class="text-caption-1 text-text-secondary">Oct 3, 11:41 PM</span>
</div>
<!-- intra-day gap -->
<div class="py-4 text-center">
<span class="text-caption-1 text-text-secondary">2:38 AM</span>
</div>
- Day divider:
div.pt-10.pb-4.text-center, textOct 3, 11:41 PM. - Time divider:
div.py-4.text-center, text2:38 AM(bare 12h time). - Zero
<time>elements anywhere in the message DOM (verified on all nodes).
Truncation
No truncation observed: a 2110-char message rendered in full, and no
"show more" / "expand" / "read more" buttons were found in any inspected
thread. Long messages are fully present in innerText.
DMs vs chat messages
No structural difference. DMs arrive as ordinary user messages in main chat; sender attribution is text-only, with two formats observed live:
- Legacy:
[from opm] [7fce46e0] <text> - Signed:
[from:operator-main] [id:fd553ef5] <text>(+-----BEGIN SSH SIGNATURE-----block)
Anything parsing DMs must match the text, not the markup. (Textual [from:X]
is unauthenticated — see DM trust notes elsewhere.)
Unread signals
hatch-nav-chataria-label:Chat, N notifications(already documented).- Page title prefix:
(1) Chat — <agent>— the count also appears indocument.title(observed(1)on pip).
Message-related testids seen in the wild
| testid | Meaning | Present when |
|---|---|---|
hatch-chat-typing-indicator |
assistant is typing/generating | generating only |
hatch-composer-stop-button |
stop-generation button | generating only |
hatch-composer-placeholder-overlay |
"Message" ghost text | composer empty |
hatch-chat-switcher-unread-indicator |
unread badge on the chat switcher | unread chats exist |
4. Chat activation button (speech-bubble / nav)
This is the dock-rail button that activates the Chat section. The user's finding: sidechat create only works when the chat function is active, and this button (not Ctrl+J) is the reliable activator.
Selector
document.querySelector('[data-testid="hatch-nav-chat"]')
Observed attributes
| Attribute | Value |
|---|---|
| tag | A (anchor, not button) |
data-testid |
hatch-nav-chat |
href |
/ |
aria-label |
dynamic: Chat or Chat, N notifications (e.g. Chat, 2 notifications) |
aria-current |
page when Chat is the active section (absent otherwise) |
| visible | true (dock rail is always rendered) |
Parent chain (4 levels)
a[data-testid="hatch-nav-chat"][aria-label="Chat, 2 notifications"][aria-current="page"]
└── div
└── div
└── div
└── div[data-testid="hatch-dock-rail"]
States
| State | Signal |
|---|---|
| Chat section active | aria-current="page" present |
| Chat section inactive | aria-current absent (button still visible/clickable) |
| Unread messages | aria-label contains N notifications |
Usage
// Activate chat reliably (replaces Ctrl+J):
document.querySelector('[data-testid="hatch-nav-chat"]').click();
cmd_sidechat_main already falls back to this click when the "Main chat"
element lookup fails. Sibling nav buttons in the same rail:
hatch-nav-search (Search), plus div[role="button"] items for Feed, Ideas,
Goals, Library, and hatch-dock-more (Settings).
5. Loading / empty states
| State | How to detect |
|---|---|
New empty thread (/thread/new) |
URL is /thread/new, document.title === "Muse", zero [data-message-id] elements, composer present |
| Main chat loaded | URL is /, title is Chat — <agent>, [data-message-id] elements present |
| Assistant generating | [data-testid="hatch-chat-typing-indicator"] exists; send button replaced by [data-testid="hatch-composer-stop-button"] |
| Idle, ready to send | typing indicator absent, stop button absent, textarea[aria-label="Message"] visible and enabled |
| Composer empty | [data-testid="hatch-composer-placeholder-overlay"] visible with text Message |
| Side panel open (threads visible) | [data-testid="hatch-chat-compose"] ("New side chat") present |
Page-title signals
| Title | Meaning |
|---|---|
Chat — <agent> |
logged in, chat UI loaded |
Muse |
partial load or /thread/new stripped state — treat as not-ready, navigate to / first |
Quick-reference selector list
// Composer
'textarea[aria-label="Message"]'
'[data-testid="hatch-composer-placeholder-overlay"]' // empty-state ghost text
// Send / stop
'button[aria-label="Send"]' // via aria-label includes 'send'
'[data-testid="hatch-composer-stop-button"]' // while generating
// Messages
'#hatch-chat-scroll' // scroll container
'div[role="log"][aria-label="Chat messages"]' // list
'[data-message-id]' // each message
'[data-message-id^="assistant-msg"]' // assistant messages only
// Chat activation
'[data-testid="hatch-nav-chat"]' // dock-rail Chat link
'[data-testid="hatch-chat-typing-indicator"]' // generating indicator
Caveats
- Selectors observed on the
opmaccount; class names are Tailwind-ish and may shift with deploys — preferdata-testid,aria-label,role, andidselectors over classes. aria-labelonhatch-nav-chatembeds a live notification count; match withstartsWith('Chat')or the testid, never exact equality.- During this capture another agent was driving the same browser; page URL
flipped between
/and/thread/newbetween probes. Element identities above were each verified at least once against the live DOM.