2026-10-04 03:48:18 +00:00
|
|
|
|
# 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`).
|
|
|
|
|
|
|
2026-10-04 04:28:21 +00:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-10-04 03:48:18 +00:00
|
|
|
|
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 <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
|
|
|
|
|
|
|
|
|
|
|
|
```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"]')
|
|
|
|
|
|
// → <span> 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
|
2026-10-04 04:28:21 +00:00
|
|
|
|
document.querySelectorAll('div[data-message-id]')
|
2026-10-04 03:48:18 +00:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-10-04 04:28:21 +00:00
|
|
|
|
**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:
|
2026-10-04 03:48:18 +00:00
|
|
|
|
|
|
|
|
|
|
| Author | `data-message-id` format | `innerText` prefix |
|
|
|
|
|
|
|-----------|--------------------------------|--------------------------|
|
|
|
|
|
|
| assistant | `assistant-msg-<uuid>` | `Assistant message: ...` |
|
2026-10-04 04:28:21 +00:00
|
|
|
|
| user | `<uuid>` (bare, no prefix) | `User message: ...` |
|
2026-10-04 03:48:18 +00:00
|
|
|
|
|
2026-10-04 04:28:21 +00:00
|
|
|
|
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).
|
2026-10-04 03:48:18 +00:00
|
|
|
|
|
|
|
|
|
|
### Extracting text / author
|
|
|
|
|
|
|
|
|
|
|
|
```js
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
```js
|
|
|
|
|
|
[...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.
|
|
|
|
|
|
|
2026-10-04 04:28:21 +00:00
|
|
|
|
### 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-scroll` to load older messages; re-query after scrolling.
|
|
|
|
|
|
- Never assert "message absent" from a single DOM snapshot; assert
|
|
|
|
|
|
"not in rendered window".
|
|
|
|
|
|
- `.group/msg` count always equals the rendered message count (every rendered
|
|
|
|
|
|
message gets the interactive wrapper + its `More options` rail 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]`:
|
|
|
|
|
|
|
|
|
|
|
|
```html
|
|
|
|
|
|
<!-- 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`, text `Oct 3, 11:41 PM`.
|
|
|
|
|
|
- Time divider: `div.py-4.text-center`, text `2: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-chat` aria-label: `Chat, N notifications` (already documented).
|
|
|
|
|
|
- Page title prefix: `(1) Chat — <agent>` — the count also appears in
|
|
|
|
|
|
`document.title` (observed `(1)` on pip).
|
|
|
|
|
|
|
2026-10-04 03:48:18 +00:00
|
|
|
|
### 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
|
|
|
|
|
|
|
|
|
|
|
|
```js
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
```js
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
|
|
```js
|
|
|
|
|
|
// 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 `opm` account; class names are Tailwind-ish and
|
|
|
|
|
|
may shift with deploys — prefer `data-testid`, `aria-label`, `role`, and
|
|
|
|
|
|
`id` selectors over classes.
|
|
|
|
|
|
- `aria-label` on `hatch-nav-chat` embeds a live notification count; match
|
|
|
|
|
|
with `startsWith('Chat')` or the testid, never exact equality.
|
|
|
|
|
|
- During this capture another agent was driving the same browser; page URL
|
|
|
|
|
|
flipped between `/` and `/thread/new` between probes. Element identities
|
|
|
|
|
|
above were each verified at least once against the live DOM.
|