197 lines
7.7 KiB
JavaScript
197 lines
7.7 KiB
JavaScript
import * as React from 'react';
|
|
import { usePreferences } from '@/lib/krowHooks';
|
|
import { allAgents } from '@/lib/agents/registry';
|
|
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 through
|
|
the one registry so the switcher and the management page cannot disagree
|
|
about what exists. */
|
|
const agents = React.useMemo(
|
|
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
|
[preferences.customAgents, 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,
|
|
};
|
|
}
|