Files
krow_talent_app/src/components/ai-assistant/AgentContext.jsx
2026-08-20 18:18:10 +05:30

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,
};
}