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

14 KiB
Raw Blame History

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:

<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.

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

// 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 lost markers 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), 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.