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
216 lines
8.7 KiB
TypeScript
216 lines
8.7 KiB
TypeScript
import * as React from 'react';
|
|
import { usePreferences } from '@/lib/krowHooks';
|
|
import { AGENTS, readAgentRegistry } from '@/lib/agents/registry';
|
|
import { sourcesFrom, useAgentDefinitions } from '@/lib/agents/agentStore';
|
|
import {
|
|
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
|
} from '@/lib/agents/runtime';
|
|
|
|
/**
|
|
* Which agent is answering.
|
|
*
|
|
* Built on the same principles as `PageContext.jsx`, and deliberately kept
|
|
* beside it rather than merged into it:
|
|
*
|
|
* - **The page is not an input to itself.** This context reads the page's
|
|
* assistant context to decide a *default*, and never the other way round.
|
|
* Nothing here can change which page the reader is on, which is what makes
|
|
* "switching agent cannot change PageContext" true by construction rather
|
|
* than by care.
|
|
* - **Session-scoped, like the panel's own window state.** Choosing an agent
|
|
* for the afternoon should not rewrite an account default, and a new tab
|
|
* should open on the page's own agent.
|
|
*
|
|
* Resolution order: what this session chose *and that still applies here* → the
|
|
* account's stated default, on the same condition → the page's own agent → the
|
|
* general agent. A page therefore always opens on the agent written for it, a
|
|
* page nobody wrote one for opens on the general agent rather than on nothing,
|
|
* and a deliberate choice still survives navigation across the pages it covers.
|
|
*
|
|
* **A selection is made somewhere.** That is the correction this file carries:
|
|
* it stores the context a choice was made on, not only the agent's id. Storing
|
|
* the id alone meant a specialist chosen on Positions followed the reader onto
|
|
* Settings and constrained a page nobody had chosen it for — the panel looked
|
|
* broken and the reason was invisible. The rule itself lives in
|
|
* `runtime.resolveSelection` as a pure function, so what applies where is
|
|
* decided in one place and can be proved without a React tree.
|
|
*/
|
|
|
|
const AgentContext = React.createContext(null);
|
|
|
|
const SESSION_KEY = 'krow_assistant:agent';
|
|
|
|
/**
|
|
* The stored selection: `{ id, contextId }`.
|
|
*
|
|
* A bare string is still read, because that is what earlier sessions wrote and
|
|
* a stored value from yesterday should not throw. It resolves as a choice made
|
|
* nowhere, which is the honest reading of it — and the conservative one, since a
|
|
* selection with no context applies only where it legitimately covers.
|
|
*/
|
|
function readSelection() {
|
|
try {
|
|
const raw = sessionStorage.getItem(SESSION_KEY);
|
|
if (!raw) return null;
|
|
if (!raw.startsWith('{')) return { id: raw, contextId: null };
|
|
const parsed = JSON.parse(raw);
|
|
return parsed?.id ? { id: parsed.id, contextId: parsed.contextId ?? null } : null;
|
|
} catch {
|
|
/* Private mode, or a value that will not parse: the session simply always
|
|
opens on the page's own agent. */
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeSelection(selection) {
|
|
try {
|
|
if (selection?.id) sessionStorage.setItem(SESSION_KEY, JSON.stringify(selection));
|
|
else sessionStorage.removeItem(SESSION_KEY);
|
|
} catch {
|
|
/* Held in memory for this session only. */
|
|
}
|
|
}
|
|
|
|
export function AgentProvider({ contextId = null, children }) {
|
|
const preferences = usePreferences();
|
|
|
|
/**
|
|
* Shipped definitions plus anything this account has authored.
|
|
*
|
|
* Read from `agent-definitions` — the store the Agent Registry and Agent
|
|
* Configure write to — rather than from `preferences.customAgents`, which is
|
|
* where authored agents used to live. That move happened for the management
|
|
* screens and this was left behind, so the panel's idea of an agent was the
|
|
* shipped file and nothing else: an agent edited in Configure looked saved,
|
|
* and the agent answering beside it was still the version off disk.
|
|
*
|
|
* It is only visible once something actually depends on an authored field.
|
|
* Attaching a skill is that: ownership is read off `agent.skills`, and an
|
|
* attachment made in Configure has to be the one the page sees, or the two
|
|
* halves of the product disagree about what this agent owns.
|
|
*/
|
|
const definitions = useAgentDefinitions();
|
|
const shippedIds = React.useMemo(() => new Set(AGENTS.map((a) => a.id)), []);
|
|
const stored = React.useMemo(
|
|
() => sourcesFrom(definitions.data || [], shippedIds),
|
|
[definitions.data, shippedIds]
|
|
);
|
|
const agents = React.useMemo(
|
|
() => readAgentRegistry(stored, { customSkills: preferences.customSkills || [] }).agents,
|
|
[stored, preferences.customSkills]
|
|
);
|
|
|
|
const [selection, setSelection] = React.useState(() => readSelection());
|
|
|
|
const select = React.useCallback((id) => {
|
|
/* Stamped with where it was chosen. A specialist picked on a page it does
|
|
not cover is a deliberate act and is honoured *here*; the stamp is what
|
|
stops it from becoming a decision about every other page too. */
|
|
const next = id ? { id, contextId } : null;
|
|
setSelection(next);
|
|
writeSelection(next);
|
|
}, [contextId]);
|
|
|
|
/** Back to whichever agent this page resolves on its own. */
|
|
const clearSelection = React.useCallback(() => {
|
|
setSelection(null);
|
|
writeSelection(null);
|
|
}, []);
|
|
|
|
/* What the stored choice means on the page the reader is on now. */
|
|
const applied = React.useMemo(
|
|
() => resolveSelection(agents, selection, contextId),
|
|
[agents, selection, contextId]
|
|
);
|
|
|
|
/**
|
|
* Storage housekeeping, in one place.
|
|
*
|
|
* Retiring a spent selection and re-stamping a live one are both writes about
|
|
* *navigation*, not about a choice, so they belong in an effect rather than in
|
|
* `select`. Both converge: the effect only writes when the stored value would
|
|
* actually change.
|
|
*/
|
|
React.useEffect(() => {
|
|
if (applied.retire) {
|
|
setSelection(null);
|
|
writeSelection(null);
|
|
return;
|
|
}
|
|
if (applied.id && selection?.contextId !== contextId) {
|
|
const next = { id: applied.id, contextId };
|
|
setSelection(next);
|
|
writeSelection(next);
|
|
}
|
|
}, [applied, selection, contextId]);
|
|
|
|
const value = React.useMemo(() => {
|
|
/**
|
|
* What this turn asks for.
|
|
*
|
|
* The session's choice first, then the account default — and the account
|
|
* default is put through the same rule, as a choice made nowhere. A stated
|
|
* default that does not cover this page is not a decision about this page,
|
|
* so it must not constrain it; the page resolves its own agent instead.
|
|
*/
|
|
const stated = applied.id
|
|
|| resolveSelection(agents, preferences.defaultAgentId || null, contextId).id
|
|
|| null;
|
|
|
|
const turn = resolveAgentForTurn(agents, stated, contextId);
|
|
/* The page's own agent, offered as the way out of a constrained state.
|
|
Null on a page nobody wrote one for — `fallback` is what answers there. */
|
|
const native = nativeAgentForContext(agents, contextId);
|
|
/* What this page opens on with nothing chosen: its own agent, or the
|
|
general one. Never null, so no page is ever left without an agent. */
|
|
const fallback = resolveDefaultAgent(agents, contextId);
|
|
|
|
return {
|
|
agents,
|
|
/* The agent that will answer. Never silently swapped: a reader who chose
|
|
one *for this page* gets that one, with `covers` saying whether it
|
|
belongs here. A choice carried in from another page is not that. */
|
|
agent: turn.agent,
|
|
covers: turn.covers,
|
|
native,
|
|
/* What this page resolves to with nothing chosen — its own agent, or the
|
|
general one. This is what a constrained answer points at, so that the
|
|
way out of a constrained state is named on every page rather than only
|
|
on the pages that have a specialist. */
|
|
defaultAgent: fallback,
|
|
/* True while this page is simply showing the agent it resolves on its
|
|
own, whether that is its specialist or the general agent. */
|
|
isNative: Boolean(fallback && turn.agent && fallback.id === turn.agent.id && !applied.id),
|
|
chosenId: applied.id,
|
|
select,
|
|
clearSelection,
|
|
/* Whether a given agent belongs on this page, for the list. */
|
|
coversPage: (candidate) => agentCovers(candidate, contextId),
|
|
};
|
|
}, [agents, applied, preferences.defaultAgentId, contextId, select, clearSelection]);
|
|
|
|
return <AgentContext.Provider value={value}>{children}</AgentContext.Provider>;
|
|
}
|
|
|
|
/**
|
|
* The active agent.
|
|
*
|
|
* Returns an inert value outside a provider, so a panel rendered in isolation —
|
|
* a test, a storybook — behaves as it did before agents existed rather than
|
|
* crashing.
|
|
*/
|
|
export function useActiveAgent() {
|
|
return React.useContext(AgentContext) ?? {
|
|
agents: [],
|
|
agent: null,
|
|
covers: true,
|
|
native: null,
|
|
defaultAgent: null,
|
|
isNative: false,
|
|
chosenId: null,
|
|
select: () => {},
|
|
clearSelection: () => {},
|
|
coversPage: () => false,
|
|
};
|
|
}
|