/** * 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(locator.selector); if (hits.length === 1 && hits[0].isConnected) return hits[0]; } catch { // Malformed selector — fall through to the role+name match. } // 2. Role + accessible name — robust to node recreation. const candidates = collectByRoleName(locator); if (candidates.length === 0) return null; return candidates.find(isRendered) ?? candidates[0]; } /** Tags worth scanning for a given role when matching by role + name. */ function tagsForRole(role: CSTInteractiveRole): string[] { switch (role) { case 'link': return ['a']; case 'button': return ['button', 'a', 'input']; case 'textbox': case 'searchbox': return ['input', 'textarea']; case 'checkbox': case 'radio': case 'slider': case 'spinbutton': return ['input']; case 'select': case 'combobox': return ['select']; case 'textarea': return ['textarea']; default: return ['button', 'a', 'input']; } } /** * Collect connected elements whose role + accessible name match the * locator. Also scans `[role=...]` so ARIA-roled elements are found. */ function collectByRoleName(locator: RefLocator): HTMLElement[] { const seen = new Set(); const selectors = [ ...tagsForRole(locator.role), `[role="${locator.role}"]`, ]; const out: HTMLElement[] = []; for (const sel of selectors) { let nodes: NodeListOf; try { nodes = document.querySelectorAll(sel); } catch { continue; } for (const node of Array.from(nodes)) { if (seen.has(node) || !node.isConnected) continue; seen.add(node); if (accessibleName(node) === locator.name) out.push(node); } } return out; }