Files
krow_talent_app/src/components/ai-assistant/useAssistant.ts
Aravind 3f835eee93 refactor(ts-migration): Phase 11 batch 7 — the assistant's non-component modules
The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.

79 errors, and two optional markers cleared 63 of them.

`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.

Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.

The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.

Measured against `dde4ba6`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  83/83 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
2026-09-18 15:35:15 +05:30

1176 lines
46 KiB
TypeScript

import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
import {
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { skillsForContext } from '@/lib/skills/registry';
import { advanceFlow } from '@/lib/skills/conversationFlow';
import { flowFor } from '@/lib/skills/flows';
import {
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
publishedReply, weightsSetReply, weightsUnchangedReply,
} from '@/lib/skills/draftFlow';
import {
assignmentDone, assignmentFailed, assignmentFollowUp, headcountFailed, headcountSet,
interviewDone, interviewFailed,
} from '@/lib/skills/workforceFlow';
import {
newConversationId, readHistory, recordFeedback, removeConversation, saveConversation,
} from './history';
import { storableContext } from '@/lib/agents/context';
import { buildFacts } from './insights';
import { agentRequest } from '@/lib/agents/runtime';
import { createAssistantProvider } from './provider';
import { preferAgent, resolveIntent } from './routing';
import { doc, text as textBlock, toSnapshots } from './blocks';
/** One provider instance for the app's lifetime. */
const provider = createAssistantProvider();
/**
* Whether a real agent is answering, as opposed to the local simulator.
*
* Read once, from the provider the app actually built. Not a separate flag: a
* second switch could disagree with the first, and "the panel thought it had an
* agent and did not" is a failure mode with no visible symptom beyond worse
* answers.
*/
const agentBacked = provider.id === 'agent';
/**
* Reads the same React Query caches the pages render from, so the assistant
* costs no extra requests and cannot be looking at a different snapshot than the
* card beside it.
*/
export function useAssistantFacts() {
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: staff = [] } = useStaff();
const { data: profiles = [] } = useWorkerProfiles();
const { data: activity = [] } = useUserActivity();
const { data: courses = [] } = useCourses();
// The signed-in worker's own record, read from cache only: `enabled: false`
// means this never runs the query, which would *create* a WorkerProfile for an
// admin who has never opened Forge. So the Forge context is personal on pages
// that already loaded it, and falls back to library-level facts elsewhere
// rather than manufacturing a record as a side effect of opening a panel.
const { data: profile = null } = useWorkerProfile({ enabled: false });
/* The signed-in account, from the shared `['user']` cache the header and the
Profile page already read. */
const { data: user = null } = useQuery({
queryKey: ['user'],
queryFn: () => base44.auth.me().catch(() => null),
staleTime: Infinity,
});
return React.useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile, user }),
[applications, postings, interviews, staff, profiles, activity, courses, profile, user]
);
}
/**
* The signed-in user's first name, for personalizing the greeting.
*
* Shares the `['user']` query key with the rest of the app, so this is a cache
* read rather than another request.
*/
export function useCurrentUserName() {
const { data } = useQuery({
queryKey: ['user'],
queryFn: () => base44.auth.me().catch(() => null),
staleTime: Infinity,
});
return data?.full_name || '';
}
/**
* Streams a locally built document through the same snapshot pipeline the
* provider uses, so a routing reply arrives exactly like any other answer
* rather than appearing instantly and reading as a different system.
*/
async function streamDocument({ document, signal, onFrame, onDone }) {
const snapshots = toSnapshots(document);
const complete = snapshots[snapshots.length - 1] || [];
let latest = [];
let interrupted = false;
for (const snapshot of snapshots) {
if (signal?.aborted) { interrupted = true; break; }
latest = snapshot;
onFrame(snapshot);
await new Promise((resolve) => setTimeout(resolve, 26));
}
/**
* An interrupted local document still settles complete.
*
* These documents are already fully computed before the first frame — the
* reveal is presentation, not generation. So an interruption should not leave
* a half-written sentence in the thread the way stopping a real generation
* would: there is nothing partial about the answer, only about the animation.
*
* This matters most after a write. The mutation refreshes every query it
* touched, the panel re-renders on the new data, and the reveal can be cut
* short — leaving the confirmation of something that definitely happened
* truncated mid-word.
*/
if (interrupted) onDone(complete);
else if (latest.length) onDone(latest);
}
/**
* The Create Position form, as an address.
*
* Named once because two things have to agree about it: nothing stored may
* offer a way back into the authoring form after a position has been saved.
*/
const AUTHORING_ROUTE = '/admin/positions/new';
/**
* The one wording every draft-continuation control resolves to.
*
* Named once because two things have to agree about it: the chip a suggestion
* writes today, and the chip a thread wrote months ago. Both must reach the
* same in-panel handler, so both are normalized to this phrasing rather than
* one being special-cased.
*/
const CONTINUE_DRAFT = /^continue the (.+) draft$/i;
/** The wording older builds used for the drafts question. */
const OLD_DRAFTS_PROMPT = /which positions are still in draft/i;
/**
* A stored chip, brought up to the current draft behavior.
*
* Threads persist as the blocks and chips they rendered, so a conversation
* written before draft continuation moved into the panel still carries the
* controls of that era: a `capability` that runs the read-only drafts report,
* or a prompt phrased so loosely that keyword routing sent it to the authoring
* form. Rewriting them in code was not enough — the ones already written down
* had to change behavior too.
*
* Deliberately narrow. Only chips that are *about continuing a draft* are
* touched, and only their routing metadata: the label the reader saw is left
* exactly as it was, so a thread reads as it always did and simply does the
* right thing when clicked.
*/
function normalizeDraftChip(chip) {
if (!chip) return chip;
const label = String(chip.label || '');
const prompt = String(chip.prompt || '');
/* "Continue the Bartender draft", however it was stored: the label is the
question, and no capability or route may intercept it. */
if (CONTINUE_DRAFT.test(label) || CONTINUE_DRAFT.test(prompt)) {
const { capability: _capability, route: _route, ...rest } = chip;
return { ...rest, prompt: CONTINUE_DRAFT.test(label) ? label : prompt };
}
/* The old drafts question ran a capability that could only describe drafts
and told the reader to open the form. The same words now resolve to the
draft flow, which continues one or offers the several by name. */
if (OLD_DRAFTS_PROMPT.test(prompt) || chip.capability === 'position-drafts') {
const { capability: _capability, route: _route, ...rest } = chip;
return { ...rest, prompt: prompt || 'Which positions are still in draft?' };
}
return chip;
}
/**
* One question, reduced to what it asks.
*
* Case, surrounding space and a trailing question mark are not differences, so
* "What should I do next?" and "what should i do next" are one question and are
* not offered twice.
*/
const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/[?.!]+$/, '');
/**
* The chips to offer after an answer: what this conversation has not covered.
*
* Two rules, and the second is the one that matters. The suggestions are ranked
* by the SERVER against the question just asked — the panel does not decide what
* is worth asking, it only decides what has already been said — and then
* anything this thread has asked or already offered is removed.
*
* Without that second rule the row repeats. A page carries a handful of intents
* and the top of that list barely moves between turns, so the same three chips
* come back after every answer, including the one the reader has just pressed.
* Removing what has been used leaves genuinely new ground each time and runs out
* honestly rather than looping.
*
* There is NO fallback to the page's own ranking, and that is the correction a
* live run forced. Asking "Summarize hiring activity" matches nothing in the
* catalogue, so nothing was excluded, so the fallback returned the page's top
* three — and the reader got "How healthy is the platform right now?" under an
* answer about hiring activity, which is the generic-catalogue behaviour this
* function exists to end. A page ranking is what to ask on a PAGE; it is not a
* follow-up to anything. When the conversation has no next question, the honest
* answer is none.
*/
export async function nextSteps({ question, history, refresh }) {
if (!refresh) return undefined;
const used = new Set([asQuestion(question)]);
for (const message of history) {
if (message.role === 'user') used.add(asQuestion(message.text));
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
}
const unused = (chips) => (chips || []).filter((chip) => {
const key = asQuestion(chip.prompt || chip.label);
if (!key || used.has(key)) return false;
/* A list that repeats itself within one turn is the same defect at a
smaller scale. */
used.add(key);
return true;
});
const onTopic = unused(await refresh({ query: question }));
return onTopic.length ? onTopic : undefined;
}
/**
* A stored thread, with any completed-then-reopen action stripped out.
*
* A reply is persisted as the blocks it rendered, so a thread written before
* this was fixed still carries the old "Continue to save" — a live control
* routing back into Create Position, offered on a position that was saved long
* ago. Removing it in code was not enough; the ones already written down had to
* stop working too.
*
* Deliberately narrow: only actions pointing at the authoring form are dropped,
* and only from stored threads. Every other block, chip and link is left as it
* was written, so a thread reads exactly as it did apart from the one control
* that should never have been there.
*/
function withoutAuthoringActions(messages = []) {
const leadsToForm = (target) => String(target || '').includes(AUTHORING_ROUTE);
return messages.map((message) => {
if (message.role !== 'assistant') return message;
const followUp = message.followUp
?.filter((chip) => !leadsToForm(chip.route))
.map(normalizeDraftChip);
const blocks = message.blocks?.map((block) => (
block?.type === 'insights'
? {
...block,
items: block.items.map(({ action, ...item }) => (
leadsToForm(action?.to) ? item : { ...item, ...(action ? { action } : null) }
)),
}
: block
));
return { ...message, ...(followUp ? { followUp } : null), ...(blocks ? { blocks } : null) };
});
}
/**
* Conversation state for one page context.
*
* Owns the thread, the in-flight response, and abort handling. The thread is
* kept per context in sessionStorage so navigating away from Candidates and back
* does not silently discard the conversation — while a stale thread from last
* week is still not resurrected.
*
* `onNavigate` is called when a question belongs to another Admin page. Both
* entry points — a suggestion chip and a typed question — go through `send`, so
* routing applies to both without either knowing it exists.
*/
export function useConversation({
contextId, facts, onNavigate, onAction, onRefreshSuggestions,
/**
* How each conversation's record gets written, keyed by the flow's id.
*
* A single `onCreatePosition` prop was the last place the panel named one
* kind of record. A second conversation needed a second prop, a second branch
* at the write, and a second set of outcome renderers — three edits to answer
* "and now employee roles too". This is one entry in a map.
*/
flowWriters = {},
/**
* The page's layout session, when the surface has one.
*
* Read for the tree Owliver inspects and called to preview or keep a change.
* Absent on every page that composes no tree, and every branch that touches
* it checks first — so the panel behaves exactly as it did before on those.
*/
uiEditing = null,
onAssignWorkers, onScheduleInterview,
/* Finishing a draft: the same two mutations the Create Position form calls.
Passed in rather than reached for, so this layer still writes nothing
itself and there is one update path for a position. */
onUpdatePosition, onGenerateDescription,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [], skillContext = null,
/* The clients this organization already staffs for, offered as chips on the
company question. Derived from postings the caller can already read. */
companies = [],
/* The caller's own postings, which is where role-to-certification relevance
is observed from. See `certificationsForRole`. */
postings = null,
/* The worker profiles a declared role can be recorded against, as
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
workers = [],
/**
* The active agent and where the reader is.
*
* Both optional. `disabledSkills` already arrives carrying the agent's
* scoping — the caller applies `agentScopedDisabled` before handing it over —
* so nothing here decides what may be read. These are for attribution: which
* agent answered, and whether it belongs on this page.
*/
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
/* The page's own name, recorded with an archived thread so History can say
where a conversation happened without resolving the context again. */
pageLabel = '',
}) {
/**
* One thread per agent per page.
*
* Switching agent starts a new conversation rather than continuing the last
* one under a different name: the answers in a thread were produced by a
* particular agent's scope, and appending a differently-scoped reply to them
* would make the thread a record of something that never happened.
*
* With no agent the key is exactly what it was, so an existing stored thread
* is still found.
*/
const storageKey = agent?.id
? `krow_assistant:${agent.id}:${contextId}`
: `krow_assistant:${contextId}`;
const flowKey = `${storageKey}:flow`;
const idKey = `${storageKey}:id`;
const [messages, setMessages] = React.useState([]);
const [pending, setPending] = React.useState(null); // { blocks, thinking }
const [error, setError] = React.useState(null);
const abortRef = React.useRef(null);
/* The archive, held in state so opening History does not have to re-read
storage on every render, and so a new turn moves its own thread to the top
of the list while it is on screen. */
const [history, setHistory] = React.useState(() => readHistory());
/**
* Which conversation this is.
*
* The thread already persisted per page; what it lacked was an identity, so
* every new conversation overwrote the last. This id is what an archived
* record is keyed by — kept in sessionStorage beside the thread it belongs
* to, so a reload continues the same conversation rather than forking it.
*/
const conversationRef = React.useRef(null);
/* A guided skill in progress — which question it is on and what it has
collected. Kept beside the thread rather than inside it, and persisted for
the same reason the thread is: leaving Positions and coming back should not
silently abandon a position half-described. */
const flowRef = React.useRef(null);
/**
* What this conversation actually used.
*
* Accumulated as turns run, never predicted from what was available. A skill
* lands here when it *answered*; a tool when `runAction` was asked to perform
* it. That distinction is the whole value of the record — a list of what the
* page offered would describe the registry, and the insight figures built on
* it would describe the registry too.
*
* A ref rather than state: nothing re-renders when it changes, and it is read
* only at the moment a thread is written.
*/
const usedRef = React.useRef({ skills: [], tools: [], knowledge: [] });
const noteUsed = React.useCallback((kind, id) => {
if (!id) return;
const bucket = usedRef.current[kind];
if (bucket && !bucket.includes(id)) bucket.push(id);
}, []);
const setFlow = React.useCallback((flow) => {
flowRef.current = flow || null;
try {
if (flow) sessionStorage.setItem(flowKey, JSON.stringify(flow));
else sessionStorage.removeItem(flowKey);
} catch {
// The flow stays in memory only.
}
}, [flowKey]);
/* Load the stored thread, and reload when the context changes. */
React.useEffect(() => {
try {
const raw = sessionStorage.getItem(storageKey);
setMessages(raw ? withoutAuthoringActions(JSON.parse(raw)) : []);
} catch {
setMessages([]);
}
try {
conversationRef.current = sessionStorage.getItem(idKey) || null;
} catch {
conversationRef.current = null;
}
try {
const raw = sessionStorage.getItem(flowKey);
flowRef.current = raw ? JSON.parse(raw) : null;
} catch {
flowRef.current = null;
}
setPending(null);
setError(null);
/* A different page or agent is a different conversation, so what the last
one used does not carry over. */
usedRef.current = { skills: [], tools: [], knowledge: [] };
}, [storageKey, flowKey, idKey]);
/**
* Writes the thread, and records it.
*
* One function rather than two calls at each site: a thread that is persisted
* but not archived is a conversation History cannot show, and every path that
* adds a turn goes through here. The conversation is given its id on its
* first turn, so an untouched panel leaves nothing behind.
*/
const persist = React.useCallback((next) => {
setMessages(next);
try {
sessionStorage.setItem(storageKey, JSON.stringify(next));
} catch {
// Quota or private mode — the thread stays in memory only.
}
if (!next.length) return;
if (!conversationRef.current) {
conversationRef.current = newConversationId();
try {
sessionStorage.setItem(idKey, conversationRef.current);
} catch {
// The id stays in memory: this conversation archives under one id for
// as long as the panel is mounted, which is the common case.
}
}
setHistory(saveConversation({
id: conversationRef.current,
contextId,
page: pageLabel,
messages: next,
agentId: agent?.id ?? null,
/* Where the question was asked, reduced — see `storableContext`. */
pageContext: owliverContext ? storableContext(owliverContext) : null,
skillsUsed: usedRef.current.skills,
toolsUsed: usedRef.current.tools,
knowledgeUsed: usedRef.current.knowledge,
}));
}, [storageKey, idKey, contextId, pageLabel, agent, owliverContext]);
/* Abort any in-flight response when the context changes or we unmount. */
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
// The stream callback runs outside render, so it needs the latest thread
// rather than the value captured when the request started.
const messagesRef = React.useRef(messages);
messagesRef.current = messages;
/**
* Asks something.
*
* `scope` runs this one turn against a *different* page and a different set
* of reachable skills, and exists for exactly one caller: testing a
* capability from the agent editor. The reader is standing on the
* configuration screen, so the panel's own context is the workspace — asking
* "what happened today?" there would resolve against the workspace's skills
* and prove nothing about the Activity capability being configured.
*
* It is an override of *which page this question is about*, never of what may
* be read: `resolveIntent` and the provider apply the same page rules to the
* substituted context that they apply to a real one, so a test cannot reach a
* record the real page would not have offered. The turn lands in the ordinary
* thread, is persisted and archived like any other, and nothing about it is
* simulated — it is the live pipeline pointed at another surface.
*/
/* The most recent question, for the confirmation path. A ref rather than
state: nothing renders from it, and making it state would re-render the
whole panel on every keystroke-completed turn for no visible reason. */
const lastQuestionRef = React.useRef('');
/* The agent version this conversation started on.
§3: running conversations pin the version they started with. Held in a ref
rather than state because nothing renders from it — and reset with the
thread, so a NEW conversation picks up whatever is current rather than
inheriting a version somebody has since moved on from. */
const pinnedVersionRef = React.useRef(0);
const send = React.useCallback(async ({
question, capability = null, positionId = null, scope = null,
}) => {
const text = String(question).trim();
if (!text) return;
/* The page this turn is about, and what is reachable there. Defaults are
the panel's own, so every existing caller is unchanged. */
const turnContext = scope?.contextId || contextId;
const turnDisabled = scope?.disabledSkills || disabledSkills;
const turnAgent = scope?.agent || agent;
const turnCovers = scope ? true : agentCoversPage;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const withUser = [...messagesRef.current, { role: 'user', text }];
messagesRef.current = withUser;
persist(withUser);
setError(null);
setPending({ blocks: [], thinking: true });
/**
* A guided skill in progress takes the message first: while Owliver is
* asking the questions, "Chennai" is an answer rather than a question about
* the page. The skill has to still be enabled — switching it off in Settings
* ends the flow rather than letting it run on without permission.
*/
let intent = null;
if (flowRef.current) {
const skill = skillsForContext(turnContext, turnDisabled, customSkills)
.find((s) => s.id === flowRef.current.skillId);
if (!skill) setFlow(null);
else {
intent = {
kind: 'flow',
skill,
...advanceFlow({
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
ctx: { roles, companies, workers, postings },
}),
};
}
}
/* A chip is this page's own capability by construction, so it never routes
away. A typed question might belong somewhere else entirely. */
if (!intent) {
intent = capability
? { kind: 'answer' }
: preferAgent(
resolveIntent({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories, companies, postings,
courses, workforce, skillContext, positionId,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
ui: uiEditing
? {
available: Boolean(uiEditing.tree?.length),
tree: uiEditing.tree,
previewing: uiEditing.previewing,
role: uiEditing.role || null,
registry: uiEditing.registry || undefined,
/* Where the reader is standing. What can be added here is a
property of the page, not of the registry, and this is how
the conversation learns it. */
page: uiEditing.page || null,
/* What this session last changed, so "change it back" has an
"it". A node id and nothing else — see `focus` on the
editing provider. */
focus: uiEditing.focus || null,
}
: null,
}),
/* Only while a real agent is behind the panel. With the local
simulator there is nothing better to defer TO, and deferring
would turn every template answer into a worse one. */
{ modelBacked: agentBacked },
);
}
/**
* A layout change, acted on before the reply says what happened.
*
* Three kinds, and the split is the safety property: a preview is shown and
* nothing is stored; an apply keeps what was already shown; an answer — a
* question back, or a refusal — changes nothing at all. Owliver never
* applies in the same turn it proposes.
*
* `propose` validates against the tree on screen and refuses rather than
* previewing something that could not be kept, so a refusal here is
* reported in the words the engine gave rather than a generic apology.
*/
if (intent.kind === 'ui-preview' && uiEditing) {
const result = uiEditing.propose(intent.op);
if (!result.ok) {
intent = {
...intent,
kind: 'ui-answer',
doc: doc(textBlock(result.problems[0]?.message || 'That change is not possible here.')),
followUp: undefined,
};
}
} else if (intent.kind === 'ui-apply' && uiEditing) {
const result = await uiEditing.apply();
if (!result.ok) {
intent = { ...intent, doc: doc(textBlock('That change could not be saved.')) };
}
} else if (intent.kind === 'ui-discard' && uiEditing) {
uiEditing.discard();
}
/**
* The one step that writes. It happens before the reply rather than after,
* because the reply is the outcome — "Position created successfully" has to
* be true when it is said.
*/
if (intent.kind === 'flow' && intent.create) {
/* The skill says which conversation this is, so the write and the wording
of its outcome both come from that registry rather than from a name
hardcoded here. */
const registry = flowFor(intent.skill);
let created = null;
/* Kept, not swallowed. The reply states the outcome, and "it did not
work" is a worse outcome to state than the reason it did not: a
required field, a refused role, or an API that is not running. */
let failure = null;
try {
created = await flowWriters[registry.id]?.(
intent.create.draft, intent.skill, intent.create.status
);
} catch (error) {
created = null;
failure = error;
}
if (created?.id) {
/**
* What can now be asked, with the position in the database.
*
* The record exists, so the organization is materially different from
* what it was one turn ago — there is one more role to fill, or one
* more unfinished draft — and what is worth asking has changed with it.
* `onRefreshSuggestions` asks the server that question again rather
* than deriving an answer here: the ranking is the API's, against the
* rows it has just written, filtered by the caller's role.
*
* A draft is offered nothing. It is not finished being specified, and
* suggesting readings of a record the admin has not committed to would
* be answering about something that is not yet true.
*/
const ready = created.status === 'active';
let refreshed = [];
if (ready) {
try {
refreshed = (await onRefreshSuggestions?.()) || [];
} catch {
/* The position was created; failing to fetch what to ask next is
not a reason to report that it was not. */
refreshed = [];
}
}
intent = {
...intent,
flow: null,
doc: registry.outcome.created(created),
followUp: [...registry.outcome.followUp(created), ...refreshed],
};
} else {
/* Keep the answers: the summary is still there to try again from. */
intent = {
...intent,
flow: { ...intent.flow, stage: 'review' },
doc: registry.outcome.failed(failure?.message),
followUp: registry.outcome.retryChips,
};
}
}
/**
* Finishing a draft. Same contract as every other write in this file: it
* happens before the reply, because the reply states the outcome.
*
* Each branch calls the mutation the form already uses and then says what
* is now true of the record — no second store, and no navigation.
*/
if (intent.kind === 'draft' && intent.perform) {
const { action, position } = intent.perform;
if (action === 'generate_description') {
let result = null;
try {
result = await onGenerateDescription?.(position);
} catch {
result = null;
}
intent = {
...intent,
doc: result?.description ? descriptionReply(position, result) : descriptionFailedReply(position),
followUp: draftActions(result?.description ? { ...position, description: result.description } : position),
};
}
if (action === 'set_weights') {
let saved = null;
try {
saved = await onUpdatePosition?.(position.id, { vetting_criteria: intent.perform.weights });
} catch {
saved = null;
}
intent = {
...intent,
doc: saved ? weightsSetReply(position, intent.perform.weights) : weightsUnchangedReply(position),
followUp: draftActions(saved || position),
};
}
if (action === 'publish') {
let published = null;
try {
published = await onUpdatePosition?.(position.id, { status: 'active' });
} catch {
published = null;
}
intent = {
...intent,
doc: published ? publishedReply(position) : publishFailedReply(position),
followUp: published ? publishedFollowUp(published) : draftActions(position),
};
}
}
/**
* The workforce write, on the same terms as the one above: it happens
* before the reply, because the reply states the new counts and those have
* to be true when they are read.
*
* `intent.assign` only exists on a turn the admin explicitly confirmed, and
* the plan inside it was re-derived from live data at that moment — so this
* writes what was agreed to or reports that it could not.
*/
if (intent.kind === 'workforce' && intent.assign) {
let done = null;
try {
done = await onAssignWorkers?.(intent.assign);
} catch {
done = null;
}
intent = {
...intent,
kind: 'answer-doc',
doc: done ? assignmentDone(intent.assign) : assignmentFailed(),
followUp: done ? assignmentFollowUp(intent.assign) : undefined,
};
}
/**
* The headcount a position never stated, supplied by the reader.
*
* Written through `useUpdateJobPosting` — the same mutation the Create
* Position form and the Positions page call — so there is one position
* record and one demand figure, not a number the panel remembers. The reply
* is the assignment proposal the reader originally asked for, re-derived
* against the saved record rather than against the number they typed, so
* what they see is what was actually stored.
*/
if (intent.kind === 'workforce' && intent.headcount) {
const { position: target, count } = intent.headcount;
let saved = null;
try {
saved = await onUpdatePosition?.(target.id, { headcount: count });
} catch {
saved = null;
}
intent = {
...intent,
kind: 'answer-doc',
...(saved
? headcountSet(saved, workforce?.context || {})
: headcountFailed(target)),
};
}
/**
* The interview step. Same contract as the assignment above: it runs before
* the reply, only on a confirmed turn, and through the app's existing
* status transition rather than a second interview store.
*/
if (intent.kind === 'workforce' && intent.interview) {
let moved = null;
try {
moved = await onScheduleInterview?.(intent.interview);
} catch {
moved = null;
}
intent = {
...intent,
kind: 'answer-doc',
doc: moved ? interviewDone(intent.interview) : interviewFailed(),
};
}
/* A skill that collects its input in the chat carries the state of that
collection on every turn — including the turn that starts it. */
if ('flow' in intent) setFlow(intent.flow);
/* What this turn used, recorded from the resolved intent rather than from
the page's offer. `intent.skill` is the definition that answered. */
noteUsed('skills', intent.skill?.id);
noteUsed('tools', intent.action?.name);
if (intent.kind !== 'answer') {
try {
await streamDocument({
document: intent.doc,
signal: controller.signal,
onFrame: (blocks) => setPending({ blocks, thinking: false }),
onDone: (blocks) => {
const next = [
...messagesRef.current,
{ role: 'assistant', blocks, followUp: intent.followUp },
];
messagesRef.current = next;
persist(next);
},
});
} finally {
setPending(null);
abortRef.current = null;
}
/* Navigate after the reply is on screen — except the reply does not
survive the move. The panel is page-scoped, so it re-mounts on the new
route and the message explaining why the reader moved is destroyed by
the navigation that message was explaining. The reader landed somewhere
else with a fresh greeting and no trace of what they asked.
So the question travels with the destination. The panel on the other
side asks it, which is what the reader wanted in the first place and
what the old copy ("ask again once the page loads") was apologising
for. Re-asking cannot loop: resolveIntent only navigates when the
destination differs from the current page. */
if (controller.signal.aborted) return;
if (intent.kind === 'navigate') onNavigate?.(intent.destination, text);
/* A skill's action runs after its reply, for the same reason: the user
should read why the form opened before it opens. */
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
return;
}
try {
let latest = [];
/* Held for the confirmation path: approving a proposed write resumes the
run, and a resumed run needs the question that produced the proposal. */
lastQuestionRef.current = text;
for await (const snapshot of provider.stream({
contextId: turnContext, capability, question: text, facts, signal: controller.signal,
/* What the agent *is*, never what it may read. The page settled that
before this call, and `agentRequest` carries no records. */
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
owliverContext,
agentVersion: pinnedVersionRef.current,
})) {
if (controller.signal.aborted) break;
latest = snapshot;
setPending({ blocks: snapshot, thinking: false });
}
// Keep whatever arrived before the user stopped it — discarding a
// half-written answer loses what they were already reading. Stopping
// before the first block, though, should leave no empty turn behind.
if (latest.length) {
/**
* What to ask NEXT — which is not the same as what is worth asking.
*
* Every other path in this file ends its turn with `followUp`; the
* agent path was the one that did not, so an agent answer was the only
* kind that left the chip row empty.
*
* The first attempt at fixing that asked for the page's untyped
* suggestions, and they are ranked by signal rather than by the
* conversation — so a page with six intents offered its top three, and
* offered the same three after every answer, including the one that had
* just been asked. Three standing highlights repeated verbatim are not
* follow-ups; they are the landing screen redrawn under a reply.
*
* So the question is passed to the server as the query, which ranks the
* same catalogue against what was actually asked, and anything this
* thread has already asked or already offered is removed. What is left
* is what this conversation has not covered yet — which is what a
* follow-up is. When nothing is left, nothing is shown: a panel with
* nothing new to suggest should say so by being quiet, not by repeating
* itself.
*
* A stopped run is offered nothing. The reader interrupted the answer,
* so the next step it implies has not been established.
*/
let followUp;
if (!controller.signal.aborted) {
try {
followUp = await nextSteps({
question: text,
history: messagesRef.current,
refresh: onRefreshSuggestions,
});
} catch {
/* The answer arrived; failing to fetch what to ask next is not a
reason to withhold it. */
}
}
const next = [
...messagesRef.current,
{
role: 'assistant',
blocks: latest,
stopped: controller.signal.aborted || undefined,
...(followUp ? { followUp } : null),
},
];
messagesRef.current = next;
persist(next);
}
} catch (e) {
if (e?.name !== 'AbortError') {
/* The reader gets one sentence; the developer gets the exception. A
capability that throws used to leave no trace anywhere, so "I could
not complete that" was the only evidence anything had gone wrong —
and it named neither the action nor the reason. */
if (import.meta.env.DEV) console.error('[owliver] action failed', e);
setError('I could not complete that. Try again in a moment.');
}
} finally {
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist, onNavigate, onAction, flowWriters, onRefreshSuggestions,
onUpdatePosition,
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
agent, agentCoversPage, agentSuggestion, owliverContext,
/* The layout session changes as a page's composition and the account's
skills resolve, and a stale one means the tree Owliver inspects is the
empty one from the first render — so a layout request falls through to
the model and comes back as "I don't cover that". */
uiEditing]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);
/**
* Approves a proposed write, and lets the run finish.
*
* The second half of the confirmation flow. The first half ended with the
* agent describing something and doing nothing; this carries the person's
* decision back and the server performs exactly the call that description was
* issued against — same tool, same arguments, same caller. A token authorises
* one write and expires; it is not a mode.
*
* The original question is re-sent alongside it, because the run that resumes
* is a NEW run: it has to be able to reach the same tool call again for the
* token to match. That is why the token is not bound to a run id — see
* tools/confirm.go.
*
* Nothing happens locally. This layer does not write, does not optimistically
* mark anything done, and does not tell the user it worked: the answer that
* comes back says what actually happened, including a refusal if the world
* moved between the asking and the answering.
*/
const confirm = React.useCallback(async (block) => {
if (!block?.token) return;
/* The question this confirmation was raised for. Read from the thread
rather than held in state, so approving an older proposal still resends
the right question rather than whatever was typed most recently. */
const question = lastQuestionRef.current;
if (!question) return;
const controller = new AbortController();
abortRef.current = controller;
setError(null);
setPending({ blocks: [], thinking: true });
try {
let latest = [];
for await (const snapshot of provider.stream({
contextId, capability: null, question, facts,
agent: agent ? agentRequest(agent, contextId) : null,
owliverContext,
confirmation: block.token,
agentVersion: pinnedVersionRef.current,
signal: controller.signal,
})) {
if (controller.signal.aborted) break;
latest = snapshot;
setPending({ blocks: snapshot, thinking: false });
}
if (latest.length) {
const next = [...messagesRef.current, { role: 'assistant', blocks: latest }];
messagesRef.current = next;
persist(next);
}
} catch (e) {
if (e?.name !== 'AbortError') {
setError('That approval could not be completed. Nothing was changed.');
}
} finally {
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, agent, owliverContext, persist]);
/**
* States something in the thread without a question having been asked.
*
* The workspace's own voice: "Attendance Analysis is now available to this
* agent." It is a real turn — persisted and archived by the same `persist`
* every answer goes through, so it survives a reload and appears in History
* exactly where it happened, rather than being a banner that evaporates.
*
* Nothing is generated. The caller supplies the sentence and the chips, which
* is what keeps this from being a second answering path: no skill runs, no
* provider is called, and nothing here can claim a figure.
*/
const announce = React.useCallback(({ text: body, followUp = null }) => {
const sentence = String(body || '').trim();
if (!sentence) return;
const message = {
role: 'assistant',
text: sentence,
blocks: doc(textBlock(sentence)).blocks,
...(followUp?.length ? { followUp } : null),
};
const next = [...messagesRef.current, message];
messagesRef.current = next;
persist(next);
}, [persist]);
/**
* Back to an empty panel.
*
* The thread being cleared is not discarded — `persist` has already archived
* every turn of it — so this releases the conversation rather than deleting
* it, and the next turn starts a new one. That is what makes "New
* conversation" and "Back to Home" safe: nothing is lost by leaving.
*/
const reset = React.useCallback(() => {
abortRef.current?.abort();
messagesRef.current = [];
setMessages([]);
setPending(null);
setError(null);
/* A new conversation abandons a half-collected position too — the questions
it was answering are no longer on screen. */
setFlow(null);
conversationRef.current = null;
try {
sessionStorage.removeItem(storageKey);
sessionStorage.removeItem(idKey);
} catch {
// Ignore.
}
}, [storageKey, idKey, setFlow]);
/**
* Reopens an archived conversation as the live thread.
*
* It resumes under its own id, so continuing an old conversation adds to that
* record instead of forking a near-duplicate beside it. Any half-collected
* skill flow is dropped: its questions belonged to the thread being left.
*/
const openConversation = React.useCallback((record) => {
if (!record?.id || !Array.isArray(record.messages)) return;
abortRef.current?.abort();
setPending(null);
setError(null);
setFlow(null);
/* Same rule as loading a stored thread: a conversation kept in History was
written before this was fixed too, and reopening it must not hand back a
route into the authoring form. */
const messages = withoutAuthoringActions(record.messages);
messagesRef.current = messages;
setMessages(messages);
conversationRef.current = record.id;
try {
sessionStorage.setItem(storageKey, JSON.stringify(messages));
sessionStorage.setItem(idKey, record.id);
} catch {
// The reopened thread stays in memory only.
}
}, [storageKey, idKey, setFlow]);
/** Forgets one archived conversation, clearing the panel if it is open. */
const forgetConversation = React.useCallback((id) => {
setHistory(removeConversation(id));
if (conversationRef.current === id) reset();
}, [reset]);
/**
* Rates the conversation on screen.
*
* The conversation rather than the turn: a reader judging an answer is
* judging the exchange that produced it, and a per-turn rating would ask them
* to score a paragraph out of context. Returns false when there is nothing to
* rate yet, so a caller can stay honest rather than pretending it landed.
*/
const submitFeedback = React.useCallback((rating, noteText = '') => {
if (!conversationRef.current) return false;
setHistory(recordFeedback(conversationRef.current, rating ? { rating, note: noteText } : null));
return true;
}, []);
/** How the conversation on screen is currently rated, or null. */
const feedback = React.useMemo(
() => history.find((r) => r.id === conversationRef.current)?.feedback ?? null,
[history]
);
return {
messages,
pending,
error,
busy: Boolean(pending),
send,
announce,
stop,
/** Approves a proposed write and resumes the run. See `confirm`. */
confirm,
reset,
submitFeedback,
feedback,
/** Every archived conversation, newest first. */
history,
/** The conversation on screen, so History can mark it. */
conversationId: conversationRef.current,
openConversation,
forgetConversation,
providerId: provider.id,
};
}