import * as React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
History, Home, Maximize2, Minimize2, PanelRightClose, Trash2, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import {
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
useGenerateJobDescription,
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
useUpdateJobPosting,
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { runAction } from '@/lib/skills/actions';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { allSkills, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
import { actionSuggestions } from '@/lib/skills/tools';
import { suggestionChips } from '@/lib/skills/serverSuggestions';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { groupByRecency } from './history';
import { useAssistantPanel } from './AssistantPanelContext';
import { usePageContext } from './PageContext';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro } from './dynamic';
import { agentScopedDisabled } from '@/lib/agents/runtime';
import { buildOwliverContext } from '@/lib/agents/context';
import { AgentBadge } from './AgentBadge';
import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
/**
* The chip row's "nothing to offer", as one shared array.
*
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
* fresh `[]` each time would rerender the row — and anything memoized against
* it — on every turn that offers nothing.
*/
const EMPTY_PROMPTS = [];
/**
* The same, for a suggestion response that has not arrived.
*
* Separate from EMPTY_PROMPTS because they are different kinds of empty: one is
* "this page offers nothing", the other is "the server has not answered yet",
* and sharing an array between them would make a pending request and a
* considered refusal indistinguishable in a dependency list.
*/
const EMPTY_SUGGESTIONS = [];
/**
* Owliver History — the conversations that came before.
*
* A list rather than a second panel: it replaces the thread in the body while
* the header and composer stay exactly where they are, so History is a state of
* the panel rather than a place you navigate to and have to find your way out
* of. Grouped by when, because that is how people look for a conversation they
* half-remember.
*/
function HistoryView({ groups, currentId, onOpen, onForget }) {
if (!groups.length) {
return (
No conversations yet
Ask Owliver something and it will be here afterwards. History is kept on this device.
);
}
return (
{groups.map((group) => (
{group.label}
{group.items.map((record) => (
{/* Destructive, so it stays out of the way until wanted rather
than sitting beside every row waiting to be mis-clicked. */}
))}
))}
);
}
/**
* Whether the composer is offering anything, as one rule in one place.
*
* Extracted because it is the whole of the interaction and every clause is a
* decision somebody could reasonably make differently:
*
* focused — an offer belongs to the thing you are about to type into. An
* unfocused composer showing suggestions is the panel talking
* first.
* !busy — nothing is offered while an answer is still arriving; the next
* question is not knowable until this one lands.
* chat — History is a different view with a different body.
* count — nothing to say, nothing shown, and this clause now carries what
* a `!typed` test used to. Typing does not hide the panel by
* rule; it changes what the panel HAS. An empty composer offers
* follow-ups, a typed action intent offers the matching actions,
* and arbitrary partial text matches no action and so offers
* nothing — which is the same outcome by a more honest route,
* and the reason "Create" can be answered while "he" cannot.
*/
export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean(
focused && !busy && view === 'chat' && count > 0
);
/**
* The questions on offer, above the composer they belong to.
*
* A labelled panel rather than a bare row of chips: the label is what makes
* three sentences read as an offer rather than as something the assistant just
* said. It sits inside the composer's own region, above the input and below the
* conversation, so it reads as part of the thing you are about to type into.
*
* Always mounted, height animated. Mounting on open would move the input the
* instant the panel appeared and again when it left, so the caret would jump
* under the reader's hands every time they focused the box. Animating a
* collapsed height keeps the geometry continuous, and `pointer-events-none`
* plus `inert`-style tab removal means the closed panel cannot be clicked or
* tabbed into.
*
* `max-h` is generous enough for four wrapped questions and scrolls past that,
* so a narrow panel on a small screen cannot push the composer off the bottom.
*/
function SuggestedQuestions({ prompts, open, onSelect }) {
return (
Suggested questions
{/* The existing chip component, and the existing submit path behind it —
`runPrompt` is the same handler a typed question goes through. */}
);
}
/**
* Keep the thread pinned to its newest content.
*
* All that survives of a floating "Back to Home" row that used to sit between
* the header and the conversation. That row was `absolute`, so it did not take
* part in the layout — it OVERLAID the top of the scrolling region, and the
* first line or two of a long answer arrived underneath it. It hid on
* down-scroll to compensate, which meant the fix for a control covering the
* answer was to make the control disappear while you read.
*
* The navigation it carried now lives in the header, where the panel's other
* controls already are and where nothing can cover the response. What is left
* here is the scroll behaviour, which was always a separate concern that had
* been folded in because the two happened to share a listener.
*
* Direct `scrollTop` rather than smooth scrolling: at streaming frequency a
* smooth scroll never catches up and the thread visibly lags the text.
*/
function usePinToBottom(scrollRef) {
return React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [scrollRef]);
}
/**
* Owliver — the dashboard's contextual panel.
*
* One component, reused on every supported page; only `context` differs.
*
* The frame is fixed at three parts — header, body, composer — and only the body
* scrolls. That is what lets the panel stay pinned beside a long dashboard
* without ever growing the page or scrolling its own input out of reach.
*
* The frame does not change between states, only the body's content does: an
* empty thread shows the greeting and this page's most relevant fact, and once a
* question is asked the same region holds the conversation. Keeping the composer
* anchored either way means the primary action never moves under the user, and
* the panel never reads as two different components.
*
* `expanded` widens the same panel into an analysis workspace. `data-wide` lets
* individual response blocks use the extra room (a KPI row goes to three
* columns) without any of them needing to know the panel's pixel width.
*
* Independent of Owliver. Answers come from a provider (see provider.js) that
* can later be pointed at an external service without changing anything here.
*/
export default function KrowAssistant({
context,
className,
expanded = false,
onClose,
onExpand,
onRestore,
}) {
const facts = useAssistantFacts();
const userName = useCurrentUserName();
const navigate = useNavigate();
const location = useLocation();
/* A question a page has handed over — see the effect below `runPrompt`. */
const {
request: panelRequest, consumeRequest,
notice: panelNotice, consumeNotice,
test: panelTest, reportTest, clearTest,
ask: askAssistant,
} = useAssistantPanel();
/* The app's own router, not a location assignment: a full page load would
discard the thread and the panel state along with it. */
const goToPage = React.useCallback(
(destination, question) => {
navigate(destination.route);
/* Hand the question to the panel that mounts on the other side. The
request lives in AssistantPanelContext, which sits ABOVE the router, so
it survives the navigation that discards this panel's thread. Without
this the reader has to retype what they just typed. */
if (question) askAssistant({ question });
},
[navigate, askAssistant]
);
/* Skills available here, and the roles they can act on — both read from what
the app already has rather than from anything written into this component.
A page whose skills change needs no edit here. */
const preferences = usePreferences();
/* `defaultAgent` rather than `native`: on a page with no agent of its own the
way out of a constrained state is the general agent, and naming nothing
there would leave the reader with a decline and no next step. */
const { agent, covers: agentCoversPage, defaultAgent } = useActiveAgent();
/**
* What this page will not offer.
*
* The account's own switched-off skills, plus everything the active agent
* does not carry. **This one memo is the entire agent scoping**: every
* consumer below — the skill list, the suggestions, the conversation, the
* tools — already takes `disabledSkills`, so none of them needed changing.
*
* It can only ever subtract. `getSkillsForPage` applies the page filter and
* this list in the same expression, so an agent cannot make a skill appear on
* a page that does not carry it, and with no agent the list is exactly the
* account's own.
*/
const accountDisabled = React.useMemo(
() => preferences.disabledSkills || [],
[preferences.disabledSkills]
);
const allRegisteredSkills = React.useMemo(
() => allSkills(preferences.customSkills || []),
[preferences.customSkills]
);
const disabledSkills = React.useMemo(
() => agentScopedDisabled(agent, allRegisteredSkills, accountDisabled),
[agent, allRegisteredSkills, accountDisabled]
);
const { data: customCategories = [] } = useRoleCategories();
const roles = React.useMemo(
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
[customCategories]
);
/* The categories Forge skills are actually filed under, so "create a kitchen
safety training" fills in the category this deployment uses rather than one
written into a skill file. */
const skillCategories = React.useMemo(
() => [...new Set((facts.forge?.library || []).map((c) => c.category).filter(Boolean))],
[facts.forge]
);
const customSkills = React.useMemo(
() => preferences.customSkills || [],
[preferences.customSkills]
);
/**
* What a declared skill reads from.
*
* The same collections `SkillSurface` hands the resolver on the page, plus
* whatever record the page has published as open. One context, one resolver:
* the card on the page and the answer in the panel are two readings of the
* same data rather than two queries that happen to agree.
*/
/**
* The page, as the API names it.
*
* `pageKeyForContext` is the existing translation from an assistant context
* id to a surface key — `admin.positions` → `positions` — and the surface
* keys are the same closed vocabulary the backend validates `page` against.
* So the panel asks about the page it is on without a second table mapping
* one to the other.
*/
const owliverPage = React.useMemo(() => pageKeyForContext(context.id), [context.id]);
const pageContext = usePageContext();
const { statesFor } = useWorkforcePaths();
const trainingPaths = React.useMemo(
() => statesFor(facts.forge?.library || [], profileForEmail(facts.profiles || [], facts.user?.email)),
[statesFor, facts.forge, facts.profiles, facts.user]
);
const { data: assignments = [] } = useAssignments();
/* Shifts worked, missed and overrun — the records behind attendance and
overtime. Read from the same cache a page would, so a card and an answer
about the same fortnight cannot disagree. */
const { data: shifts = [] } = useShiftRecords();
/* Where the reader is, composed from the page's own channel — see
`lib/agents/context.js`. Read-only: nothing here writes back to the page. */
const owliverContext = React.useMemo(
() => buildOwliverContext({ context, pathname: location.pathname, pageContext }),
[context, location.pathname, pageContext]
);
const skillContext = React.useMemo(() => ({
...pageContext,
applications: facts.applications || [],
positions: facts.postings || [],
interviews: facts.interviews || [],
courses: facts.forge?.library || [],
workerProfiles: facts.profiles || [],
/* The same records under the workforce engine's name, and the commitments
it reads availability from — so a source that scores candidates against a
position resolves here exactly as it does on the page. */
profiles: facts.profiles || [],
assignments,
staff: facts.staff || [],
activity: facts.activity || [],
shifts,
trainingPaths,
}), [pageContext, facts, assignments, shifts, trainingPaths]);
/**
* Which face of the panel the body is showing.
*
* A state rather than a route: the header, the composer and the panel's width
* are unchanged between the two, and only the scrolling region swaps. That is
* also why there is always a way back — leaving History is a state change this
* component owns, not a browser-history entry it has to hope exists.
*/
const [view, setView] = React.useState('chat');
/**
* A skill asked for something to happen. The names come from the skill file;
* `runAction` decides what they mean and refuses anything the file did not
* declare.
*/
const performAction = React.useCallback((action, skill) => {
const result = runAction(action.name, { ...action.payload, skill });
if (result?.type === 'navigate' && result.route) {
navigate(result.route, result.state ? { state: result.state } : undefined);
}
}, [navigate]);
/**
* Write the position the conversation collected.
*
* The same mutation the Create Position form calls, so there is one create
* path and the list behind the panel refreshes the way it always did. The
* skill still has to declare `create_position` — `runAction` returns nothing
* for a skill whose file does not, and then nothing is written.
*/
const createJob = useCreateJobPosting();
const createPosition = React.useCallback(async (draft, skill, status) => {
const result = runAction('create_position', { draft, skill, status });
if (result?.type !== 'create_position') return null;
return createJob.mutateAsync(result.data);
}, [createJob]);
/**
* Every conversation's write, keyed by the flow's id.
*
* `useAssistant` looks the writer up by the flow the answering skill declares,
* so adding a conversation is adding an entry here rather than another prop
* threaded through the panel.
*/
const createRole = useCreateWorkerWithRole();
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
const result = runAction('create_employee_role', { draft, skill, status });
if (result?.type !== 'create_employee_role') return null;
return createRole.mutateAsync(result.data);
}, [createRole]);
/* The page's layout session, mounted by the layout above both this panel and
the page. Null on a surface that composes no tree, which is every page that
has not migrated — and every branch that reads it checks first. */
const uiEditing = useUiEditing();
const flowWriters = React.useMemo(() => ({
position: createPosition,
'employee-role': createEmployeeRole,
}), [createPosition, createEmployeeRole]);
/**
* The clients this organization already staffs for.
*
* Distinct company names off the postings the panel has already loaded for
* this caller — org-scoped by the API, and nothing here widens that. They are
* offered as chips on the conversation's company question so an existing
* client is a tap, while typing a name that is not on the list is still how a
* new one is named. There is no company record to create: see the `@companies`
* note in `lib/skills/flows/position.js`.
*
* Deliberately NOT sorted here. The order is the one the postings arrived in
* — the API's `-created_date` — so the clients staffed for most recently are
* the ones offered first, and the panel does no ranking of its own. That last
* part is a rule `npm test` enforces structurally, and it is the right rule:
* a second opinion formed in the panel outranking the server's is exactly the
* failure that decays quietly.
*/
/**
* The workers a role can be recorded against.
*
* The profiles the panel already holds for this caller — org-scoped by the
* API. The conversation offers the names as chips and resolves a pick back to
* the profile id and email, so the row names a real person rather than
* whatever was typed. The operator is never the subject: the question is
* required and there is no fallback to the session.
*/
const workers = React.useMemo(() => (facts.profiles || []).map((w) => ({
id: w.id,
name: w.full_name || w.name || '',
email: w.email || '',
})).filter((w) => w.email), [facts.profiles]);
const companies = React.useMemo(() => [...new Set(
(facts.postings || []).map((p) => String(p.company || '').trim()).filter(Boolean)
)], [facts.postings]);
/**
* What to ask next, from the server, after something has been written.
*
* The untyped form of the suggestions endpoint: nothing has been typed, so
* the API ranks against the organization's actual state — the rows the
* mutation just changed. Asking it here rather than deriving an answer in the
* panel is the whole point. The panel knows a position now exists; only the
* server knows whether that makes drafts, starved roles or an unscreened
* queue the thing worth raising, and only it knows what this caller's role
* permits.
*
* `fetchQuery` with `staleTime: 0` rather than a bare request: it resolves
* with the fresh answer *and* leaves it in the cache under the key the hook
* reads, so the chips this reply carries and the chips the composer would
* offer are one answer rather than two requests apart. Zero rather than the
* hook's thirty seconds because a cached answer is exactly what must not be
* used here — it was computed before the row existed.
*/
const queryClient = useQueryClient();
const refreshSuggestions = React.useCallback(async ({ query = '' } = {}) => {
if (!owliverPage) return [];
const fresh = await queryClient.fetchQuery({
/* An empty query is the untyped request — the same key
`useOwliverSuggestions` uses when the composer is empty, and what a
write wants: the page changed, so ask what matters on it now.
A question passed in ranks the same catalogue AGAINST that question,
which is what makes a follow-up follow from something. */
queryKey: ['owliverSuggestions', owliverPage, query],
queryFn: () => fetchOwliverSuggestions({ page: owliverPage, query }),
staleTime: 0,
});
return suggestionChips(fresh || [], context.id);
}, [queryClient, owliverPage, context.id]);
/**
* Finishing a draft, through the mutations the Create Position form calls.
*
* `useUpdateJobPosting` is the same update the form and the Positions page
* use, and `useGenerateJobDescription` is the same generator behind the
* form's "Generate Job Description with AI" button. Reusing both is what
* keeps one position record, one description and one set of vetting weights
* — the panel changes the record, it does not keep a copy of it.
*/
const updateJob = useUpdateJobPosting();
const onUpdatePosition = React.useCallback(
(id, data) => updateJob.mutateAsync({ id, data }),
[updateJob]
);
const generateDescription = useGenerateJobDescription();
const onGenerateDescription = React.useCallback(
async (position) => {
const result = await generateDescription.mutateAsync({ data: position, draftId: position.id });
return result || null;
},
[generateDescription]
);
/**
* The workforce picture the panel reasons over.
*
* The same collections the Positions page reads, so a recommendation and the
* card behind it are looking at one moment. Assembled here rather than in the
* routing layer because this is where the app's data already is; routing stays
* pure and testable.
*/
const workforce = React.useMemo(() => ({
positions: facts.postings || [],
/* The position the page has open, so "who matches this position?" resolves
against the record in front of the reader rather than asking which role
they meant. A page with nothing open publishes nothing, and the question
is answered by name or asked for, exactly as before. */
currentPositionId: pageContext.position?.id || null,
context: {
profiles: facts.profiles || [],
applications: facts.applications || [],
assignments,
courses: facts.forge?.library || [],
staff: facts.staff || [],
},
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments,
pageContext.position?.id]);
/**
* The workforce write, through the mutation the app already has.
*
* `useAssignWorkers` creates the assignment, moves the application and logs
* the activity — one path, reused rather than reimplemented. It only ever runs
* on a turn the admin explicitly confirmed.
*/
const assignWorkers = useAssignWorkers();
const onAssignWorkers = React.useCallback(async (plan) => {
const created = await assignWorkers.mutateAsync({
position: plan.position,
workers: plan.workers,
applications: facts.applications || [],
});
return created?.length ? created : null;
}, [assignWorkers, facts.applications]);
/**
* Move an assigned candidate to interview stage.
*
* The status transition the Position and Candidates pages already perform —
* not a new interview record. `AIInterviewModal` writes the AIInterview when
* the interview is actually conducted, and creating an empty one here would be
* a second interview store claiming something that has not happened.
*/
const markInterviewReady = useMarkInterviewReady();
const onScheduleInterview = React.useCallback(
(plan) => markInterviewReady.mutateAsync({ application: plan.application, position: plan.position }),
[markInterviewReady]
);
const {
messages, pending, error, busy, send, announce, stop, reset,
history, conversationId, openConversation, forgetConversation,
submitFeedback, feedback, confirm,
} = useConversation({
contextId: context.id,
facts,
pageLabel: context.page,
onNavigate: goToPage,
onAction: performAction,
flowWriters,
uiEditing,
companies,
/* The postings this caller can already see — the evidence behind
role-aware certification suggestions. */
postings: facts.postings || [],
workers,
onRefreshSuggestions: refreshSuggestions,
onUpdatePosition,
onGenerateDescription,
onAssignWorkers,
onScheduleInterview,
workforce,
disabledSkills,
customSkills,
roles,
skillCategories,
courses: facts.forge?.library || [],
skillContext,
agent,
agentCoversPage,
agentSuggestion: defaultAgent,
owliverContext,
});
/* A block inside an answer asking the next question, in place. Same entry
point as the composer and the chips, so an in-panel action is an ordinary
turn rather than a second way for the panel to change. */
const askOwliver = React.useCallback((question) => {
if (!busy) send({ question });
}, [busy, send]);
const historyGroups = React.useMemo(() => groupByRecency(history), [history]);
/**
* Home is the empty panel: greeting, page fact, suggestions.
*
* One handler for every way back — the History header button, the Back to
* Home control, and reopening after reading an old thread — so "home" cannot
* mean two slightly different states depending on how you got there. Nothing
* is lost: the thread being left is already archived.
*/
const goHome = React.useCallback(() => {
setView('chat');
reset();
}, [reset]);
const openHistoryItem = React.useCallback((record) => {
openConversation(record);
setView('chat');
}, [openConversation]);
const [input, setInput] = React.useState('');
const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending;
const pinToBottom = usePinToBottom(scrollRef);
/* Whether there is anything to leave. The header's own control is shown on
exactly the states that are not already home — the same test the removed
row used, now deciding a button rather than an overlay. */
const canGoHome = view === 'history' || messages.length > 0 || Boolean(pending);
/* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */
const intro = React.useMemo(
() => buildIntro(context.id, facts, userName),
[context.id, facts, userName]
);
/**
* What the composer offers, and when.
*
* Three rules, and they are about DIFFERENT questions — what to show, and
* whether to show anything at all.
*
* WHAT. Before the first question, the page's own suggested questions: the
* reader has asked nothing, so there is nothing to follow up and the useful
* offer is the range of what this page can answer. After an answer, the
* follow-ups that answer carried — questions this conversation has not
* already covered, worked out in `nextSteps`. Never both: a thread that has
* run out of new ground shows nothing rather than falling back to the
* catalogue it has already been through.
*
* WHEN. Only while the composer has focus and is empty. Suggestions used to
* appear from the second character typed, which is the wrong moment twice
* over: a reader who is typing has already decided what to ask, and two
* characters is not enough to know what they mean. So typing hides them and
* the reader's own text is never touched.
*
* The typed-query branch is gone with it. The endpoint still takes a query
* and `nextSteps` still uses it — that is what makes a follow-up follow from
* something — but nothing asks it on a keystroke any more.
*/
const followUp = messages[messages.length - 1]?.followUp;
const typed = input.trim();
const [composerFocused, setComposerFocused] = React.useState(false);
/* The page's own questions, for a thread that has not started. One untyped
request, cached by the hook, asked only while it could be shown. */
const { data: pageSuggestions = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
page: owliverPage,
enabled: Boolean(owliverPage) && messages.length === 0,
});
/**
* What is on offer, which now depends on whether anything has been typed.
*
* TYPED — the actions this page can perform that the text is starting to
* name, and nothing else. "Create" reaches "Create a position" here because
* that is a skill on this page that declares an action; "he" reaches nothing,
* and neither does "abc". This is the narrow case the composer was missing:
* a reader typing an action intent had to finish the sentence unaided, while
* a reader typing anything at all used to get the whole page catalogue.
*
* EMPTY — the follow-ups the last answer left, or, before a thread starts,
* what this page can be asked. Unchanged.
*/
const reachableSkills = React.useMemo(
() => skillsForContext(context.id, disabledSkills, preferences.customSkills || []),
[context.id, disabledSkills, preferences.customSkills]
);
const prompts = React.useMemo(() => {
if (typed) return actionSuggestions(typed, reachableSkills);
if (messages.length) return followUp?.length ? followUp : EMPTY_PROMPTS;
return suggestionChips(pageSuggestions, context.id);
}, [typed, reachableSkills, messages.length, followUp, pageSuggestions, context.id]);
const showSuggestions = shouldShowSuggestions({
focused: composerFocused, busy, view, count: prompts.length,
});
/**
* Focus, read at the composer rather than at the input.
*
* A chip lives inside the same region, so moving to one keeps the region
* focused and the panel open long enough for the click to land — which a
* `blur` handler on the textarea alone would not do. `relatedTarget` is what
* makes "clicked outside" mean it: focus leaving for anywhere else in the
* document closes the panel.
*/
const onComposerBlur = React.useCallback((event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setComposerFocused(false);
}, []);
/**
* Asking closes the panel, and only focus reopens it.
*
* A blur handler alone was not enough, which a live run showed: after a
* question was sent, focus ended up on `document.body` while `composerFocused`
* was still true, so the panel came back on its own under the finished answer
* with nobody's cursor in the box. The subtree re-renders while the answer
* streams, and a focus lost that way does not always arrive as a blur this
* handler sees.
*
* So submitting is treated as what it is — the reader has finished with the
* composer for now — rather than relying on a blur that may never come. The
* state table is unchanged: focus opens it, everything else leaves it shut.
*/
React.useEffect(() => {
if (busy) setComposerFocused(false);
}, [busy]);
/* The newest assistant turn, which is the one that carries the rating. */
const lastAnswerIndex = React.useMemo(
() => messages.map((m) => m.role).lastIndexOf('assistant'),
[messages]
);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
visibly lags the text. */
React.useEffect(() => {
pinToBottom();
}, [messages, pending, pinToBottom]);
/**
* The one entry point for every message, whatever raised it.
*
* A chip carries a capability — it *is* one of this page's answers, so it
* bypasses intent routing. A typed question carries none and is routed. Both
* land in the same `send`, so there is a single pipeline to reason about.
*/
const handleMessage = React.useCallback((
question, capability = null, positionId = null, scope = null,
) => {
const text = String(question).trim();
if (!text || busy) return;
setInput('');
/* Asking something while reading History returns to the conversation — the
answer is about to arrive there, and leaving the list up would hide it. */
setView('chat');
send({ question: text, capability, positionId, scope });
}, [busy, send]);
/**
* A chip is already a complete question, so it runs on click.
*
* A chip may instead carry a destination — "View position", offered after
* Owliver creates one. That is the user choosing to go somewhere after the
* work is finished, which is the only navigation any of this does.
*/
const runPrompt = React.useCallback((prompt) => {
if (prompt.route) {
navigate(prompt.route);
return;
}
/* A chip built from a position says which one, so a follow-up acts on the
record the previous answer was about rather than re-deriving it from the
words. */
handleMessage(prompt.prompt, prompt.capability, prompt.positionId);
}, [handleMessage, navigate]);
/**
* A question the page asked on the reader's behalf.
*
* Continue on a draft card is the only source today: the card knows which
* record it is, and the work of finishing it lives here. Deferred while a
* response is streaming — the effect re-runs when the panel is free, so the
* request waits its turn rather than being dropped for arriving mid-answer.
*/
React.useEffect(() => {
if (!panelRequest || busy) return;
handleMessage(
panelRequest.question, panelRequest.capability ?? null,
panelRequest.positionId, panelRequest.scope,
);
if (panelRequest.trace) reportTest(panelRequest.id, 'running');
consumeRequest(panelRequest.id);
}, [panelRequest, busy, handleMessage, consumeRequest, reportTest]);
/**
* Reporting the end of a traced turn.
*
* The panel is the only place that knows a turn finished, and a reader who
* pressed Test is waiting on exactly that. `busy` falling is the signal;
* `error` decides which outcome it was. Nothing is inferred about the
* *content* of the answer — a capability that answered is a pass, and judging
* whether the answer was any good is the reader's job, which is the whole
* reason the answer is shown to them rather than summarised.
*/
React.useEffect(() => {
if (panelTest?.state !== 'running' || busy) return;
reportTest(panelTest.id, error ? 'error' : 'done', error || null);
}, [panelTest, busy, error, reportTest]);
/* A question the reader types themselves ends the test banner: they have
moved on, and leaving "Testing…" above an unrelated answer would label
something that is no longer happening. */
const submit = React.useCallback((text) => {
if (panelTest) clearTest();
handleMessage(text);
}, [handleMessage, panelTest, clearTest]);
/**
* Something the workspace told Owliver, said in the thread.
*
* Attaching a skill to an agent is the only source today. It is stated rather
* than asked, so it appends a turn instead of running one — see
* `AssistantPanelContext.announce` for why those are different acts.
*
* Deferred while a response streams, for the same reason a request is: a
* notice arriving mid-answer waits for the panel rather than landing between
* a question and its reply. Reading History is left alone — the notice is in
* the live thread when the reader comes back to it.
*/
React.useEffect(() => {
if (!panelNotice || busy) return;
announce({ text: panelNotice.text, followUp: panelNotice.followUp });
consumeNotice(panelNotice.id);
}, [panelNotice, busy, announce, consumeNotice]);
const composer = (
);
return (
{/* Header */}
{/* The avatar and the two lines beside it — "Owliver" over the page,
and who is answering. It is a label, not a control: choosing an
agent belongs to Agent Registry → Configure, not to this header.
The header element, its geometry and the window controls to the
right are unchanged. */}
{/* Window controls.
Authoring a skill is deliberately not among them. Skills are a
registry with a lifecycle — authored, enabled, edited, removed — and
Workspace → Skills is where that lifecycle lives. A second entry
point here meant the panel you *use* Owliver from was also a place
you *configured* it from, and the two lists could be reached from
different places with different affordances. The panel does the
first job only; the registry behind it is unchanged. */}
{/* The way back to a clean panel, positioned in front of History */}
{canGoHome && (
)}
{/* History lives with the other window controls rather than in the
body, so the layout of the panel is unchanged whether or not
there is anything to show. It toggles: pressing it again returns
to the conversation you were reading. */}
setView((v) => (v === 'history' ? 'chat' : 'history'))}
/>
{expanded
? onRestore && (
)
: onExpand && (
)}
{onClose && (
)}
{/**
* A capability test running through this panel.
*
* One line, in the panel's own vocabulary, so the reader knows the next
* answer is the thing they asked for rather than the page talking to
* itself. Deliberately outside the scrolling body: it describes the turn,
* not a message in it, and nothing about it is persisted with the thread.
*/}
{panelTest && (
)}
{/* ── Body: the only region that scrolls ─────────────────────────── */}
{view === 'history' ? (
) : isEmpty ? (
/* Greeting and the single most relevant fact about this page. Top
aligned rather than centred: it is the first thing in a
conversation, not a splash screen, so it belongs where the first
message would be. */
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
{/* Focus is tracked on the whole region rather than on the textarea, so
reaching for a suggestion does not close the panel out from under the
click. In normal flow, never floating: an overlay here would sit on
top of the answer, which is the mistake the removed Back to Home row
made. The body above is `flex-1`, so it yields the height and the
response stays whole and scrollable. */}