353 lines
14 KiB
Markdown
353 lines
14 KiB
Markdown
# muse.ai Edge States DOM Reference
|
||
|
||
> **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.
|
||
|
||
> Captured live via CDP on `opm` browser (warp-opm netns, CDP 9440) on 2026-10-04.
|
||
> Re-verified 2026-10-04 across `muse` (9410), `pip` (9420), `opm` (9440) —
|
||
> see §10. No alerts, toasts, approval dialogs, or offline markers on any node.
|
||
> Method: settled-state probe + `Page.reload` with timed probes at t=0.5/1.5/3/6/10s
|
||
> to catch transient load states.
|
||
> Baseline page: `https://muse.ai/thread/2e2c90a1-4b17-4590-b8ba-fb17d8018d36`
|
||
> (a real sidechat thread), title `Chat — operator-main`.
|
||
> Companion docs: `DOM-CHAT-PANEL.md`, `DOM-MESSAGES.md`, `DOM-PAGE-STRUCTURE.md`.
|
||
> This is a development reference for reliable automation. Prefer these selectors
|
||
> over text matching.
|
||
|
||
---
|
||
|
||
## 1. Loading states
|
||
|
||
### 1.1 Skeleton shimmer (the actual "spinner")
|
||
|
||
There is **no** `animate-spin` spinner, no `[role="progressbar"]`, and no
|
||
`aria-busy="true"` anywhere in the app DOM. Loading is shown with shimmer
|
||
skeleton spans:
|
||
|
||
```html
|
||
<span class="rounded-6 motion-reduce:animate-none animate-pulse-light bg-fill-primary text-te ..."></span>
|
||
```
|
||
|
||
**Key facts:**
|
||
|
||
| Property | Value |
|
||
|---|---|
|
||
| Selector | `span.animate-pulse-light` (class match: `/animate-pulse-light/`) |
|
||
| `data-testid` | None — class is the only hook |
|
||
| Present in settled state | **Yes** (2 hits even when fully loaded) |
|
||
| Parent chain | `SPAN > DIV > DIV > DIV > DIV` (no testids in chain — not localizable by testid) |
|
||
| `role="status"` count | 4–7 (see 1.3) |
|
||
|
||
Because shimmer spans exist in the settled state too, **do not use them as a
|
||
"still loading" signal**. They mark skeleton slots (e.g. avatar/media slots),
|
||
not page load progress.
|
||
|
||
### 1.2 Avatar/media loading slot
|
||
|
||
```html
|
||
<div class="group-has-[[data-hatch-avatar-media-loading]]/status-avatar:hidden ..."></div>
|
||
```
|
||
|
||
A container that hides the avatar until `data-hatch-avatar-media-loading` clears.
|
||
Class-match: `/data-hatch-avatar-media-loading/`. Present in settled state;
|
||
same caveat as 1.1 — not a page-load signal.
|
||
|
||
### 1.3 Screen-reader live regions (always present, usually empty)
|
||
|
||
| Element | `role` | `aria-live` | `data-testid` | Content |
|
||
|---|---|---|---|---|
|
||
| `SPAN` ×2 | `status` | `polite` | — | empty |
|
||
| `DIV` | `log` | `polite` | — | **the message list** (see 1.4) |
|
||
| `DIV` | `status` | — | `hatch-chat-typing-indicator` | empty when idle |
|
||
| `DIV` | `status` | `polite` | — | empty |
|
||
| `SECTION` | — | `polite` | — | empty |
|
||
|
||
Count varies 4–7 across probes. They are persistent accessibility plumbing, not
|
||
transient loading UI. The `role="log"` div **is** the message list container.
|
||
|
||
### 1.4 Ready-state transition (reload sequence)
|
||
|
||
What the DOM looks like as the page settles after `Page.reload` on a thread URL:
|
||
|
||
| t (s) | `document.readyState` | Title | Buttons | Markers | Notes |
|
||
|---|---|---|---|---|---|
|
||
| 0.5 | `complete` | `Muse` | 10 | none | **S3 partial** — nav rail only |
|
||
| 1.5 | `complete` | `Muse` | 15 | none | **S3 partial** — switcher appears |
|
||
| 3.0 | `complete` | `Chat — operator-main` | 49 | `retry`* | settled |
|
||
| 6.0 | `complete` | `Chat — operator-main` | 49 | `retry`* | settled |
|
||
| 10.0 | `complete` | `Chat — operator-main` | 51 | `retry`* | settled |
|
||
|
||
\* `retry` is a **false positive** (see §5.1). Note `readyState` is already
|
||
`complete` at t=0.5 — **do not use `readyState` as a readiness signal**; the
|
||
React app hydrates after it.
|
||
|
||
**Settled-state signature** (what "ready" looks like for automation):
|
||
|
||
```javascript
|
||
document.title.startsWith('Chat —') &&
|
||
!!document.querySelector('[data-testid="hatch-chat-switcher-trigger"]') &&
|
||
document.querySelectorAll('button').length > 30
|
||
```
|
||
|
||
---
|
||
|
||
## 2. S3 PARTIAL_LOAD dissected
|
||
|
||
The transient state the reliability investigation called S3 (title `Muse`,
|
||
~10–21 buttons, no switcher or a just-appeared switcher). Captured at t=0.5
|
||
and t=1.5 after reload.
|
||
|
||
### 2.1 t=0.5 — nav rail only (10 buttons)
|
||
|
||
```text
|
||
button text='' aria='Search' testid='hatch-nav-search'
|
||
button text='' aria='Settings' testid='hatch-dock-more'
|
||
button text='Show in library' aria=None testid=None
|
||
button text='' aria='Close panel' testid='hatch-status-panel-close'
|
||
button text='' aria='Edit avatar and name' testid=None
|
||
button text='' aria='Activity' testid=None
|
||
button text='' aria='Approvals' testid=None
|
||
button text='' aria='Upcoming' testid=None
|
||
button text='' aria='Identity' testid=None
|
||
button text='' aria='Attach file' testid=None
|
||
```
|
||
|
||
No chat switcher. No compose `+`. No `hatch-nav-chat`. The left dock rail
|
||
renders before the chat app.
|
||
|
||
### 2.2 t=1.5 — switcher appears (15 buttons)
|
||
|
||
Same as above, plus:
|
||
|
||
```text
|
||
button text='' aria='Get the Muse app' testid=None
|
||
button text='' aria='Back to main chat' testid=None
|
||
button text='[JOB heartbeat-20261004-034747-6b3d4128]'
|
||
aria=None testid='hatch-chat-switcher-trigger'
|
||
button text='' aria='More options' testid=None
|
||
button text='' aria='Dictate a message' testid=None
|
||
```
|
||
|
||
Notable: the switcher's visible text is the **current thread title** (here the
|
||
auto-generated heartbeat thread title), not the literal `Chats`. The
|
||
`Back to main chat` button exists this early — it is the S8 escape hatch even
|
||
during partial load.
|
||
|
||
**Update 2026-10-04:** the thread-title switcher text is also the *settled*-state
|
||
behavior (verified on muse/pip/opm thread pages, e.g.
|
||
`646 pip cross-operator coordination chat | Open chat and side chats`).
|
||
Never match the switcher by text — use the testid. Likewise, the
|
||
`Back to main chat` aria-label button is present on **regular `/thread/<uuid>`
|
||
pages too**, not only during partial load — S8 recovery works from any thread
|
||
state, panel open or closed.
|
||
|
||
### 2.3 Detection and recovery
|
||
|
||
```javascript
|
||
// S3 detection
|
||
document.title === 'Muse' &&
|
||
!document.querySelector('[data-testid="hatch-chat-switcher-trigger"]') &&
|
||
document.querySelectorAll('button').length < 30 &&
|
||
!window.location.href.includes('/thread/new')
|
||
```
|
||
|
||
Recovery (from the state-machine design): sleep 5s, re-probe; if still S3,
|
||
`Page.reload` via CDP, re-probe; cap at 2 reloads, then abort with a state
|
||
dump. Never treat S3 as a failure of the *action* — it is a transient render
|
||
phase.
|
||
|
||
---
|
||
|
||
## 3. Empty states
|
||
|
||
### 3.1 Sidechat panel: closed vs open-but-empty
|
||
|
||
```javascript
|
||
const shell = document.querySelector('[data-testid="hatch-side-chats-panel-shell"]');
|
||
const isOpen = shell && shell.innerHTML !== '';
|
||
const rows = document.querySelectorAll('[data-testid="hatch-thread-row"]').length;
|
||
```
|
||
|
||
| Condition | Meaning |
|
||
|---|---|
|
||
| `shell.innerHTML === ''` | Panel closed (shell is always in DOM, emptied when closed) |
|
||
| `shell.innerHTML !== '' && rows === 0` | Panel open, no chats listed |
|
||
| `rows > 0` | Panel open with chats |
|
||
|
||
Caveat: open-but-empty was **not directly observed** (opm has sidechats); the
|
||
`rows === 0` check is the detection to use, but the visual empty-state copy is
|
||
unrecorded.
|
||
|
||
### 3.2 Message list
|
||
|
||
The message list is the `DIV[role="log"][aria-live="polite"]` (plain DIVs, no
|
||
testids up the chain — 5 levels of bare `DIV`). Observed with 2 children in a
|
||
populated thread. No `no messages` / `start a conversation` / `nothing here
|
||
yet` text markers exist anywhere in `document.body.innerText` in the observed
|
||
states. An empty thread's exact DOM is unrecorded — detect via
|
||
`log.children.length === 0`.
|
||
|
||
### 3.3 Composer placeholder
|
||
|
||
`[data-testid="hatch-composer-placeholder-overlay"]` exists in the testid
|
||
inventory (observed in settled state). It overlays the composer when empty.
|
||
|
||
---
|
||
|
||
## 4. Typing indicator
|
||
|
||
```html
|
||
<div role="status" data-testid="hatch-chat-typing-indicator"></div>
|
||
```
|
||
|
||
| Property | Value |
|
||
|---|---|
|
||
| Selector | `[data-testid="hatch-chat-typing-indicator"]` |
|
||
| Tag / role | `DIV` / `status` |
|
||
| Present when idle | **No** — transient; absent from DOM in one probe, present-but-empty in another |
|
||
| Inner HTML when idle | `""` (empty) |
|
||
|
||
Useful as a "someone is typing / response streaming" signal: present with
|
||
content means activity. Do not assume it is always in the DOM.
|
||
|
||
---
|
||
|
||
## 5. Error banners and toasts
|
||
|
||
### 5.1 Nothing observed — and two documented traps
|
||
|
||
Across all probes: **zero** toasts (`[data-testid*="toast"]`,
|
||
`[data-sonner-toast]`, `*toast*` classes), **zero** `[role="alert"]` elements
|
||
with content, and none of these body-text markers:
|
||
`something went wrong`, `try again`, `failed to load`, `went wrong`, `oops`,
|
||
`error`, `blocked`, `suspended`.
|
||
|
||
Two false positives to not trip on:
|
||
|
||
| Marker | Actual source | Why it matches |
|
||
|---|---|---|
|
||
| `retry` | `DIV.text-subheadline-medium`: **"Refine JOB/DM system and retry"** — an auto-generated sidechat title from earlier automation | substring match on chat content |
|
||
| `retry` | a `<script>` tag (React bundle code contains "retry") | `body.innerText` includes script text in some probes |
|
||
| `loading` | `<script>` bundle code | same as above |
|
||
|
||
**Rule: never trust bare `innerText.includes()` for error detection.** Scope
|
||
error searches to rendered, non-script elements, or better, to `[role]`
|
||
regions:
|
||
|
||
```javascript
|
||
[...document.querySelectorAll('[role="alert"], [role="status"]')]
|
||
.filter(e => e.tagName !== 'SCRIPT' && (e.innerText||'').trim().length > 0)
|
||
```
|
||
|
||
### 5.2 Approval dialogs (the one real "banner" class)
|
||
|
||
Permission dialogs (`Allow … to share`) are detected by `check_approvals` in
|
||
`muse-chat-api.py`: it only treats dialogs containing an **IP address** as
|
||
real, and auto-approves IPs in `TRUSTED_IPS` (VM/bl). Dialogs without IPs are
|
||
ignored as false positives (chat content mentioning "allow"). Full structure
|
||
is in `DOM-PAGE-STRUCTURE.md`.
|
||
|
||
---
|
||
|
||
## 6. Offline / disconnected
|
||
|
||
- `navigator.onLine` → `true` (via CDP eval).
|
||
- No `offline`, `reconnecting`, `disconnected`, `connection lost` markers in
|
||
body text in any observed state.
|
||
- No service-worker or connectivity UI observed.
|
||
|
||
Detection guidance for automation:
|
||
|
||
```javascript
|
||
// poll alongside state probes
|
||
({ onLine: navigator.onLine,
|
||
markers: /offline|reconnecting|disconnected|connection lost/i.test(
|
||
document.body ? document.body.innerText : '') })
|
||
```
|
||
|
||
If `onLine === false` or a marker appears: stop acting, back off, re-probe;
|
||
do not click through — a disconnected React app can swallow clicks silently.
|
||
|
||
---
|
||
|
||
## 7. Rate limit / blocked states
|
||
|
||
No `rate limit`, `too many requests`, `slow down` markers observed in any
|
||
state. The app's own rate limiting was not triggerable from these probes.
|
||
|
||
What we know from automation history (AGENTS.md): the constraint that matters
|
||
is **our own send rate** — use the shared `bin/rate_limiter.py` token bucket
|
||
(1 op / 3s, burst 5, max 20/min per agent). There is no observed DOM signal
|
||
for server-side throttling; if sends start failing opaquely, back off and
|
||
check `dm-log.jsonl` / `job-log.jsonl` rather than the DOM.
|
||
|
||
---
|
||
|
||
## 8. Iframes and shadow DOM (no automation barrier)
|
||
|
||
### 8.1 Iframes — all marketing pixels
|
||
|
||
| `src` | `title` |
|
||
|---|---|
|
||
| `https://muse.ai/api/marketing/campaign-manager-pixel-frame` | Campaign Manager measurement |
|
||
| `https://muse.ai/api/marketing/dv360-pixel-frame` | DV360 measurement |
|
||
| `https://muse.ai/api/marketing/snapchat-pixel-frame` | Snapchat measurement |
|
||
| `https://muse.ai/api/marketing/tiktok-pixel-frame` | TikTok measurement |
|
||
|
||
4 iframes, all 1×1-style measurement pixels. None contain app UI.
|
||
|
||
### 8.2 Shadow DOM
|
||
|
||
One shadow host: `<next-route-announcer>` (Next.js route announcer, empty).
|
||
Not app UI. **No shadow-DOM barrier** for automation selectors, confirming
|
||
the earlier DOM investigation.
|
||
|
||
---
|
||
|
||
## 9. Automation cheat-sheet
|
||
|
||
| Signal | Selector / check | Meaning |
|
||
|---|---|---|
|
||
| Ready | `title.startsWith('Chat —') && has('[data-testid="hatch-chat-switcher-trigger"]') && buttons > 30` | settled, act |
|
||
| S3 partial | `title === 'Muse' && !has(switcher) && buttons < 30` | wait / reload (cap 2) |
|
||
| S8 stripped | `url.includes('/thread/new') && !has(switcher)` | click `Back to main chat` or `hatch-nav-chat` |
|
||
| Chat active | `has('[data-testid="hatch-chat-compose"]')` | panel open, ready to create |
|
||
| Panel open | `shell.innerHTML !== ''` | shell = `[data-testid="hatch-side-chats-panel-shell"]` |
|
||
| Empty chat list | `rows === 0` | rows = `[data-testid="hatch-thread-row"]` |
|
||
| Typing/streaming | `has('[data-testid="hatch-chat-typing-indicator"]')` with content | response in flight |
|
||
| Real error | non-empty `[role="alert"]` / IP-bearing approval dialog | stop, escalate |
|
||
| Offline | `!navigator.onLine` or offline markers | back off, re-probe |
|
||
|
||
**Never use as signals:** `document.readyState` (already `complete` during S3),
|
||
`body.innerText.includes('retry'/'loading'/'Side chats')` (false positives
|
||
documented above), switcher `aria-expanded` (never set), switcher **inner text**
|
||
(it's the thread title, not `Chats`), shimmer
|
||
`animate-pulse-light` spans (present when settled).
|
||
|
||
## 10. Re-verification log (2026-10-04, inspector 5/5)
|
||
|
||
Probed `muse`, `pip`, `opm` via CDP `Runtime.evaluate` in each netns:
|
||
|
||
| Check | muse | pip | opm |
|
||
|---|---|---|---|
|
||
| `navigator.onLine` | true | true | true |
|
||
| `[role="alert"]` with content | 0 | 0 | 0 |
|
||
| Toasts | 0 | 0 | 0 |
|
||
| `Allow … to share` in body text | no | no | no |
|
||
| Offline/rate-limit markers | none | none | none |
|
||
| Typing indicator | absent | absent | absent |
|
||
| Composer present | yes | yes | yes |
|
||
|
||
Additional observations:
|
||
- **Title unread prefix:** pip's title was `(1) Chat — operator-pip` — the title
|
||
gains an `(N)` prefix when unreads exist. State detectors should use
|
||
`startsWith('Chat —')` on the *end* of the title or strip a leading
|
||
`\(\d+\)\s*` before comparing.
|
||
- **Shared browsers:** another agent drove pip's browser mid-probe (URL changed
|
||
from `/thread/<uuid>` to `/` and back, panel toggled between probes).
|
||
Fleet browsers are a shared resource — re-probe immediately before acting,
|
||
and never treat a minutes-old snapshot as current state.
|
||
- **Settled-state counts are ranges:** shimmer spans 1 (doc said 2),
|
||
`role="status"` 3–4 (doc said 4–7), buttons 46–57 on settled thread pages.
|
||
The cheat-sheet's `buttons > 30` threshold still holds; don't hard-code
|
||
exact counts.
|