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
155 lines
5.9 KiB
TypeScript
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]
|
|
);
|
|
}
|