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
This commit is contained in:
154
src/components/ai-assistant/PageContext.tsx
Normal file
154
src/components/ai-assistant/PageContext.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user