import * as React from 'react'; /** * What the page is currently looking at, published to the panel beside it. * * A skill's data source declares what it needs — a position, a candidate, the * position being specified — and the page is the only thing that knows which * one is open. Without this, Owliver could read the collections but not the * *selection*: it would answer about the workspace when it was asked about the * role in the drawer. * * The channel is deliberately narrow. A page publishes records it already has; * it does not publish components, callbacks or view state, and nothing here * reaches back into the page. That keeps the coupling one-way — a page that * publishes nothing simply has no selection, and every skill whose source needs * one says so instead of guessing. * * usePublishPageContext({ position }); // in the page * const { position } = usePageContext(); // in the panel * * Kept out of `AssistantPanelContext` on purpose: that context is the panel's * window state, changing when someone drags a divider. This changes when the * reader opens a record, and merging the two would re-render one for the other. */ const PageContext = React.createContext(null); export function PageContextProvider({ children }) { const [entity, setEntity] = React.useState(null); /** * The writes a page is willing to accept, keyed by data source. * * The read channel above is serialized state; this cannot be — a handler is a * function, closing over the page's own setter. So it lives in a ref, and only * the *set of source ids* is state. That distinction is the whole design: * consumers re-render when a page starts or stops accepting a write, not every * time the page re-renders and hands over a new closure. * * Keyed by source id rather than by an invented action name, because the * source is already the validated binding between a definition and its data. * A skill therefore cannot write anywhere it could not read, and a page cannot * accept a write for a reading it does not own. */ const handlersRef = React.useRef({}); const [writable, setWritable] = React.useState([]); const publishActions = React.useCallback((handlers) => { handlersRef.current = handlers || {}; const next = Object.keys(handlersRef.current).sort(); /* Only a change in *what* is writable is worth a render. */ setWritable((prev) => (prev.join('|') === next.join('|') ? prev : next)); }, []); /** * Runs a page's handler for a source. Returns false when the page is not * accepting that write, so a caller can stay read-only rather than pretending * the change landed. */ const runAction = React.useCallback((source, payload) => { const handler = handlersRef.current[source]; if (typeof handler !== 'function') return false; handler(payload); return true; }, []); const value = React.useMemo( () => ({ entity, setEntity, publishActions, runAction, writable }), [entity, publishActions, runAction, writable] ); return {children}; } /** * Publishes this page's current selection for as long as it is mounted. * * Keyed on the content rather than the object, because a page rebuilds its * records on every render and an identity-keyed effect would publish in a loop. * Unmounting clears the selection, so leaving a page cannot leave Owliver * answering about a record nobody is looking at. */ export function usePublishPageContext(entity) { const context = React.useContext(PageContext); const setEntity = context?.setEntity; /* `undefined` values would vanish from the serialized key, so a selection being cleared has to read as an explicit null. */ const key = React.useMemo(() => { try { return entity ? JSON.stringify(entity) : null; } catch { return null; } }, [entity]); React.useEffect(() => { if (!setEntity) return undefined; setEntity(key ? JSON.parse(key) : null); return () => setEntity(null); }, [key, setEntity]); } /** * The records the page has published. Always an object, so a consumer can read * `position` without checking whether anything published at all. */ export function usePageContext() { return React.useContext(PageContext)?.entity || {}; } /** * Offers this page's writes for as long as it is mounted. * * Pass a memoized map of `{ [sourceId]: handler }`. Handlers should be stable — * a functional `setState` closes over nothing, which is what lets the map be * memoized once rather than rebuilt as the form is typed into. * * const applyWeights = useCallback( * (next) => setForm((f) => ({ ...f, vetting_criteria: next })), []); * usePublishPageActions(useMemo( * () => ({ 'position.vetting': applyWeights }), [applyWeights])); * * Unpublished on unmount, so leaving a page cannot leave a control on screen * that writes into a form nobody is looking at. */ export function usePublishPageActions(handlers) { const publishActions = React.useContext(PageContext)?.publishActions; React.useEffect(() => { if (!publishActions) return undefined; publishActions(handlers); return () => publishActions(null); }, [publishActions, handlers]); } /** * How a section writes back, for one source — or `null` when nothing on this * page is accepting that write. * * Returning null rather than a no-op is deliberate: a renderer checks it to * decide whether to draw controls at all, so a section declared `editable` on a * page that does not own the data stays an honest read-out. */ export function usePageAction(source) { const context = React.useContext(PageContext); const accepted = Boolean(source) && (context?.writable || []).includes(source); const runAction = context?.runAction; return React.useMemo( () => (accepted && runAction ? (payload) => runAction(source, payload) : null), [accepted, runAction, source] ); }