/** * Re-locatable ref descriptors. * * A snapshot is captured when a chat message is sent; the AI's * `point` directive arrives seconds later. In between, React re-renders * the page — the chat panel expands, content streams in, lists update — * and React recreates DOM nodes. Any `HTMLElement` reference frozen at * capture time is then detached (`isConnected === false`) and useless. * * So a ref must NOT be stored as a node pointer. It is stored as a * `RefLocator`: a description rich enough to FIND the element again in * whatever DOM exists at resolve time. `resolveLocator` runs a live * query against the current `document` — it never dereferences a stale * pointer. */ import { accessibleName } from '../capture/accessible-name'; import { tagName } from '../capture/dom-utils'; import type { CSTInteractiveRole } from '../cst/types'; /** * A description that can re-find an interactive element in the live DOM. * * `role` + `name` is the primary locator: the AI cited an element by the * accessible name the CST gave it, so "the visible `link` named 'Browse * Catalog'" survives node-identity changes and even position shifts. * `selector` is a structural tiebreaker used to disambiguate when * several elements share the same role + name. */ export interface RefLocator { /** Interactive role, as classified by the CST walk. */ role: CSTInteractiveRole; /** Accessible name — the label the AI was shown for this element. */ name: string; /** lowercased tag name, used to filter candidates. */ tag: string; /** * A CSS selector that re-finds the element. Built from a stable * attribute (`id` / `data-testid` / `name`) when one exists, else a * structural `nth-of-type` path from the nearest id'd ancestor or * `
`. A tiebreaker, not the source of truth — React can * recreate nodes and shift positions, breaking the path. */ selector: string; } /** Attributes that, when present, give a stable unique-ish selector. */ const STABLE_ATTRS = ['data-testid', 'id', 'name'] as const; /** True when a selector matches exactly one element in `document`. */ function isUnique(selector: string): boolean { try { return document.querySelectorAll(selector).length === 1; } catch { return false; } } /** Escape a value for use inside a CSS attribute selector. */ function cssEscape(value: string): string { if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { return CSS.escape(value); } return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`); } /** * Build a structural `nth-of-type` path from the nearest id'd ancestor * (or ``) down to `el`. Survives re-renders that recreate nodes * but keep the same tag structure and ordering — the common React case. */ function structuralPath(el: HTMLElement): string { const parts: string[] = []; let node: Element | null = el; while (node && node !== document.body && node !== document.documentElement) { const id = node.getAttribute('id'); if (id) { parts.unshift(`#${cssEscape(id)}`); return parts.join(' > '); } const tag = tagName(node); const parent: Element | null = node.parentElement; if (!parent) { parts.unshift(tag); break; } let index = 1; for (const sib of Array.from(parent.children)) { if (sib === node) break; if (tagName(sib) === tag) index += 1; } parts.unshift(`${tag}:nth-of-type(${index})`); node = parent; } return parts.length ? `body > ${parts.join(' > ')}` : 'body'; } /** * Compute a re-locatable descriptor for an interactive element at * capture time. `role` is already known from classification; `name` is * derived with the same accessible-name logic the CST walk uses, so the * locator's name matches exactly what the AI was shown. */ export function computeLocator( el: HTMLElement, role: CSTInteractiveRole, ): RefLocator { let selector = ''; for (const attr of STABLE_ATTRS) { const raw = el.getAttribute(attr); if (!raw) continue; const candidate = attr === 'id' ? `#${cssEscape(raw)}` : `${tagName(el)}[${attr}="${cssEscape(raw)}"]`; if (isUnique(candidate)) { selector = candidate; break; } } if (!selector) selector = structuralPath(el); return { role, name: accessibleName(el), tag: tagName(el), selector, }; } /** True when an element is rendered (has layout) — prefers visible hits. */ function isRendered(el: HTMLElement): boolean { if (!el.isConnected) return false; // offsetParent is null for display:none; width/height covers fixed // and body-level elements that legitimately have no offsetParent. return ( el.offsetParent !== null || el.getClientRects().length > 0 ); } /** * Find a connected element matching a locator against the live DOM. * * Resolution order: * 1. the structural / attribute selector — if it yields exactly one * connected element, trust it; * 2. the role + accessible name — scan candidate tags, match the live * accessible name, prefer a rendered element. This is the resilient * path: it does not depend on node identity or position. * 3. give up — the element is genuinely gone (stale ref). */ export function resolveLocator(locator: RefLocator): HTMLElement | null { // 1. Selector — a precise hit when structure held. try { const hits = document.querySelectorAll