12 KiB
muse.ai Edge States DOM Reference
Captured live via CDP on
opmbrowser (warp-opm netns, CDP 9440) on 2026-10-04. Method: settled-state probe +Page.reloadwith 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), titleChat — 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:
<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
<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):
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)
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:
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
// 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
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
<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:
[...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 lostmarkers in body text in any observed state. - No service-worker or connectivity UI observed.
Detection guidance for automation:
// 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).