'use client'; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useLocationProperty } from '@djangocfg/ui-core/hooks'; import { installChatBridge, setBridgeResolver, } from '../../browser-bridge'; import { useChatSettings } from '../../../tools/chat/settings'; import type { CaptureEngineOptions, CaptureResult } from '../engine'; import { PageSnapshotEngine } from '../engine'; import { PageSnapshotContext, type PageSnapshotContextValue, } from './use-page-snapshot'; /** Props for `PageSnapshotProvider`. */ export interface PageSnapshotProviderProps { /** Engine configuration. */ config?: CaptureEngineOptions; /** * Opt-in value used when nothing is stored yet. Default false — the * user must explicitly enable sharing page context. Once the user * toggles it, the choice is persisted and this is ignored. */ defaultLinked?: boolean; /** * localStorage key for the centralized chat-settings object that holds * the opt-in (along with the rest of the chat's persisted settings). * Override to scope the chat settings per app / per chat. */ storageKey?: string; children: ReactNode; } /** * Provides the page-snapshot capability to the chat. * * - Constructs the engine lazily, browser-only (hydration-safe). * - Holds the opt-in state. * - `getChatMetadata` is the contributor handed to * `ChatProvider.getDynamicMetadata`: it captures a fresh snapshot at * send time (capture-on-submit) and returns `{ pageContext }`. * - Auto-captures on route change; tracks staleness via content hash. */ export function PageSnapshotProvider({ config, defaultLinked = false, storageKey, children, }: PageSnapshotProviderProps) { // The opt-in choice persists across sessions — the user enables screen // sharing once and it is remembered. It now lives in the centralized // chat-settings object (`ChatSettings.pageContext.linked`) instead of a // standalone key, so the chat owns all its persisted state in one place. const settingsDefaults = useMemo( () => ({ pageContext: { linked: defaultLinked } }), [defaultLinked], ); const { settings, setPageContextLinked } = useChatSettings({ storageKey, defaults: settingsDefaults, }); const isLinked = settings.pageContext.linked; const setIsLinked = setPageContextLinked; const [isStale, setIsStale] = useState(false); const [lastSnapshot, setLastSnapshot] = useState(null); // The snapshot that actually rode the most recent chat message. // // CST ref ids (`@eN`) are positional — assigned by DOM-order traversal // at capture time — so they are NOT stable across captures: `@e22` of // one snapshot need not be `@e22` of another. A `point` directive the // assistant returns cites refs from the snapshot it was given. To // resolve that directive correctly the overlay must use the registry // of *that* snapshot, not a newer one. // // `lastSnapshot` tracks the live page and is overwritten by every // route-change auto-capture, so it cannot be trusted for directive // resolution. This state holds the sent snapshot and is only ever // replaced on the next send — it survives route changes that happen // while a reply (and its directives) is still streaming in. const [sentSnapshot, setSentSnapshot] = useState(null); // Engine is browser-only. Construct it once, lazily, after mount — // a ref (not state) since it never needs to trigger a re-render. const engineRef = useRef(null); const getEngine = useCallback((): PageSnapshotEngine | null => { if (typeof window === 'undefined') return null; if (!engineRef.current) { engineRef.current = new PageSnapshotEngine(config); } return engineRef.current; }, [config]); // The latest content hash — used to decide if the page changed. const lastHashRef = useRef(null); /** * Run a capture; on success update lastSnapshot + hash + staleness. * * Capture is synchronous (a DOM walk, single-digit ms) so there is no * `isProcessing` flag to toggle around it — that would only add two * needless re-renders. */ const runCapture = useCallback( (track: boolean): CaptureResult | null => { const engine = getEngine(); if (!engine) return null; try { const result = engine.capture(); if (track) { setLastSnapshot(result); lastHashRef.current = result.payload.metadata.contentHash; setIsStale(false); } return result; } catch { // Capture must never break the chat — fail soft. return null; } }, [getEngine], ); const capture = useCallback((): CaptureResult | null => { if (!isLinked) return null; return runCapture(true); }, [isLinked, runCapture]); const generatePreview = useCallback( (): CaptureResult | null => runCapture(false), [runCapture], ); const refresh = useCallback(() => { runCapture(true); }, [runCapture]); /** * The chat metadata contributor. Captures fresh at call time — the * chat invokes this synchronously at send (capture-on-submit). */ const getChatMetadata = useCallback((): Record | undefined => { if (!isLinked) return undefined; const result = capture(); if (!result) return undefined; // This is the snapshot the assistant will see — pin it so any // `point` directive in the reply resolves against the exact ref // registry that produced the refs the assistant cited. setSentSnapshot(result); return { pageContext: result.payload }; }, [isLinked, capture]); // Reactive pathname from ui-core's router abstraction. Framework- // agnostic (History API under the hood) — no next/navigation // dependency, works in Next / Vite / CRA / Electron alike. const pathname = useLocationProperty( () => window.location.pathname, () => '/', ); // Route change → auto-capture a fresh snapshot (capture, not send). // Also covers the initial opt-in: when `isLinked` flips true this // effect runs and populates `lastSnapshot`. useEffect(() => { if (!isLinked) return; runCapture(true); // `pathname` in deps: a new route re-captures automatically. }, [isLinked, pathname, runCapture]); // Install the dev-only `window.__chatBridge` once — manual testing of // highlight / focus commands without an AI round-trip. useEffect(() => { installChatBridge(); }, []); // Keep the bridge's ref resolver pointed at the latest snapshot, so // console-driven `window.__chatBridge.highlight()` commands resolve // refs against the current page. This is intentionally the *latest* // snapshot, not `sentSnapshot`: manual testing highlights what is on // screen now. AI-directive resolution is a separate path — it uses // `sentSnapshot` (see `DirectiveOverlay`) so it stays pinned to the // registry the assistant actually saw. useEffect(() => { setBridgeResolver(lastSnapshot?.refs ?? null); return () => setBridgeResolver(null); }, [lastSnapshot]); const value = useMemo( () => ({ isLinked, setIsLinked, capture, generatePreview, getChatMetadata, lastSnapshot, sentSnapshot, isStale, refresh, }), [ isLinked, capture, generatePreview, getChatMetadata, lastSnapshot, sentSnapshot, isStale, refresh, ], ); return ( {children} ); }