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 {children}; } /** * 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, }; }