/** * ElementRegistry — Singleton that tracks all interactive UI elements * * Elements are registered by the AgentKitProvider's introspection engine * or manually via the useAgentKit hook. The registry provides query methods * used by the CommandHandler to find and act on elements. */ import type { ElementInfo, ElementType, RegisteredElement, ElementHandlers, ScreenState, } from './types'; import { Dimensions } from 'react-native'; type ChangeListener = (state: ScreenState) => void; /** * Check if an element's ref is in a visible subtree. * Walks up the fiber tree from the ref looking for hidden indicators * (aria-hidden, activityState) set by react-navigation/native-stack. */ function isRefVisible(ref: React.RefObject): boolean { const node = ref?.current; if (!node) return true; // no ref — can't determine, assume visible let fiber = node.__internalInstanceHandle ?? node._internalFiberInstanceHandleDEV; while (fiber) { const props = fiber.memoizedProps; if (props) { if (props['aria-hidden'] === true) return false; if (props.activityState === 0) return false; } fiber = fiber.return; } return true; } class ElementRegistryImpl { private elements: Map = new Map(); private listeners: Set = new Set(); private autoIdCounter = 0; /** * Register an element in the registry. */ register( id: string, info: Omit, ref: React.RefObject, handlers: ElementHandlers = {} ): string { const elementId = id || this.generateId(info.type); const element: RegisteredElement = { info: { ...info, id: elementId }, ref, handlers, source: 'manual', }; this.elements.set(elementId, element); this.notifyListeners(); return elementId; } /** * Unregister an element by ID. */ unregister(id: string): boolean { const deleted = this.elements.delete(id); if (deleted) { this.notifyListeners(); } return deleted; } /** * Update an element's info (partial update). */ update(id: string, partial: Partial): boolean { const element = this.elements.get(id); if (!element) return false; element.info = { ...element.info, ...partial }; // Don't notify on every minor update — batch via scan interval return true; } /** * Update an element's handlers. */ updateHandlers(id: string, handlers: Partial): boolean { const element = this.elements.get(id); if (!element) return false; element.handlers = { ...element.handlers, ...handlers }; return true; } /** * Get a registered element by ID. */ get(id: string): RegisteredElement | undefined { return this.elements.get(id); } /** * Find elements by their text label (case-insensitive partial match). */ findByText(text: string): RegisteredElement[] { const lower = text.toLowerCase(); return Array.from(this.elements.values()).filter( (el) => el.info.label.toLowerCase().includes(lower) || el.info.value?.toLowerCase().includes(lower) || el.info.placeholder?.toLowerCase().includes(lower) ); } /** * Find elements by type. */ findByType(type: ElementType): RegisteredElement[] { return Array.from(this.elements.values()).filter( (el) => el.info.type === type ); } /** * Find an element by ID or text. ID match is preferred. */ findByTarget(target: string): RegisteredElement | undefined { // First try exact ID match const byId = this.elements.get(target); if (byId) return byId; // Then try label/text match (return first match) const byText = this.findByText(target); return byText[0]; } /** * Get all visible elements as ElementInfo array. * Filters out elements whose ref is in a hidden subtree (e.g. off-screen). */ getAll(): ElementInfo[] { return Array.from(this.elements.values()) .filter((el) => isRefVisible(el.ref)) .map((el) => el.info); } /** * Get current screen state snapshot (visible elements only). */ getScreenState(): ScreenState { const { width, height } = Dimensions.get('window'); const visibleElements = this.getAll(); return { elements: visibleElements, count: visibleElements.length, screen: { width, height }, }; } /** * Clear all registered elements. */ clear(): void { this.elements.clear(); this.notifyListeners(); } /** * Get count of visible elements. */ get size(): number { return this.getAll().length; } /** * Get total count including hidden elements (for internal use). */ get totalSize(): number { return this.elements.size; } /** * Subscribe to registry changes. */ addListener(listener: ChangeListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } /** * Bulk update elements (used by introspection scans). * Updates introspected elements in-place to preserve action-modified state * (e.g. checked state after toggle). Removes stale introspected elements * and adds newly discovered ones. Manual elements are always preserved. */ bulkUpdate( elements: Array<{ id: string; info: Omit; ref: React.RefObject; handlers: ElementHandlers; }> ): void { const newIds = new Set(elements.map((el) => el.id)); // Remove introspected elements no longer in the scan (e.g. off-screen) for (const [key, el] of this.elements) { if (el.source === 'introspected' && !newIds.has(key)) { this.elements.delete(key); } } // Add or update introspected elements for (const el of elements) { const existing = this.elements.get(el.id); // Don't overwrite a manual element with an introspected one if (existing && existing.source === 'manual') { continue; } if (existing && existing.source === 'introspected') { // Update in-place: refresh ref, handlers, and info BUT preserve // state fields that may have been modified by actions (e.g. toggle). existing.ref = el.ref; existing.handlers = el.handlers; existing.info = { ...el.info, id: el.id, state: { ...el.info.state, checked: existing.info.state.checked }, }; } else { // New element — add fresh this.elements.set(el.id, { info: { ...el.info, id: el.id }, ref: el.ref, handlers: el.handlers, source: 'introspected', }); } } this.notifyListeners(); } private generateId(type: string): string { return `${type}-${++this.autoIdCounter}`; } private notifyListeners(): void { const state = this.getScreenState(); for (const listener of this.listeners) { try { listener(state); } catch (e) { // Swallow listener errors to avoid breaking the registry console.warn('[AgentKit] Registry listener error:', e); } } } } /** Singleton instance */ export const ElementRegistry = new ElementRegistryImpl();