DOM reference: edge states, loading, and error handling\n\n- S3 PARTIAL_LOAD dissected with timed probes\n- readyState useless; settled-state signature documented\n- False-positive traps for innerText.includes()
This commit is contained in:
@@ -0,0 +1,311 @@
|
|||||||
|
# muse.ai Edge States DOM Reference
|
||||||
|
|
||||||
|
> Captured live via CDP on `opm` browser (warp-opm netns, CDP 9440) on 2026-10-04.
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
### 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), shimmer
|
||||||
|
`animate-pulse-light` spans (present when settled).
|
||||||
Reference in New Issue
Block a user