Files
krow_talent_app/src/components/ai-assistant/PageContext.tsx
Aravind 7a95d95151 refactor(ts-migration): Phase 11 batch 8 — the assistant panel components
The thirteen `.jsx` files under `src/components/ai-assistant/`, which
completes that directory: 24/24 files migrated and all 24 linted under
their new extensions.

39 errors, and converting 21 `/** @param {any} props */` hatches to
`: any` cleared 33 of them — the same pattern as batch 6, and the same
reason: without the JSDoc, TypeScript infers every destructured prop as
required, so `ResponseBlocks` and `AssistantMessage` alone produced 31
complaints about call sites that were always correct.

The remaining six were already there before this batch and are
unchanged.

13/13 erase byte-identically. `.jsx` -> `.tsx` is the move that can drop
unused imports through esbuild's loader difference; it did not here, and
the bundle hash is unchanged.

Measured against `3f835ee`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings, 24/24 linted by name
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  96/96 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:37:09 +05:30

155 lines
5.9 KiB
TypeScript

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 <PageContext.Provider value={value}>{children}</PageContext.Provider>;
}
/**
* 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]
);
}