Files
box/docs/DOM-EDGE-STATES.md

353 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.