import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
} 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, useCreateJobPosting, useGenerateJobDescription,
useMarkInterviewReady, useUpdateJobPosting,
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { runAction } from '@/lib/skills/actions';
import { skillsForContext } from '@/lib/skills/registry';
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
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, buildPrompts } from './dynamic';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
/**
* 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. */}
))}
))}
);
}
/**
* Show the Back to Home row on scroll direction, not scroll position.
*
* Hidden while reading downwards, back the instant the user scrolls up — the
* way out of a long answer should not be something you have to scroll all the
* way to the top to reach. Direction is read off the panel's own scrolling
* element (the body region below), never the window: the page behind the panel
* does not move when the thread does.
*
* The listener is passive and rAF-throttled, and `setVisible` is only ever
* called with a value that can change — React bails out on an identical one, so
* a fast scroll costs at most one render per direction change.
*
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
* routed through here so a programmatic jump is not mistaken for the user
* scrolling down and does not hide the control under them.
*/
function useDirectionalNav(scrollRef, { active, resetKey }) {
const [visible, setVisible] = React.useState(true);
const lastY = React.useRef(0);
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
produce: a real direction change clears this within one frame. */
const NOISE = 4;
const pinToBottom = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
/* Adopt the new position before the scroll event lands, so the next read
sees no delta and the row keeps whatever state the user left it in. */
lastY.current = el.scrollTop;
}, [scrollRef]);
React.useEffect(() => {
const el = scrollRef.current;
/* Nothing to hide when the row is not rendered — and a fresh thread or a
newly opened panel always starts with it showing. */
setVisible(true);
if (!el || !active) return undefined;
lastY.current = el.scrollTop;
let frame = 0;
const read = () => {
frame = 0;
const y = el.scrollTop;
if (y <= 0) {
lastY.current = y;
setVisible(true);
return;
}
const delta = y - lastY.current;
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
rather than being swallowed frame by frame. */
if (Math.abs(delta) < NOISE) return;
lastY.current = y;
setVisible(delta < 0);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(read);
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, [scrollRef, active, resetKey]);
return { visible, pinToBottom };
}
/**
* 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();
/* A question a page has handed over — see the effect below `runPrompt`. */
const { request: panelRequest, consumeRequest } = 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) => navigate(destination.route),
[navigate]
);
/* 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();
const disabledSkills = React.useMemo(
() => preferences.disabledSkills || [],
[preferences.disabledSkills]
);
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]
);
const skills = React.useMemo(
() => skillsForContext(context.id, disabledSkills, customSkills),
[context.id, disabledSkills, 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.
*/
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();
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 || [],
trainingPaths,
}), [pageContext, facts, assignments, 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]);
/**
* 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, stop, reset,
history, conversationId, openConversation, forgetConversation,
} = useConversation({
contextId: context.id,
facts,
pageLabel: context.page,
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
onUpdatePosition,
onGenerateDescription,
onAssignWorkers,
onScheduleInterview,
workforce,
disabledSkills,
customSkills,
roles,
skillCategories,
courses: facts.forge?.library || [],
skillContext,
});
/* 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;
/* The Back to Home row only exists in the states that are not already home. */
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
active: showBackRow,
resetKey: `${view}:${conversationId || ''}`,
});
/* 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]
);
/* Suggestions are the page's own, with any skill that offers one on the front.
The last answer can also propose follow-ups — the role list after "create a
position" — which replace the standing set until the next turn. */
const followUp = messages[messages.length - 1]?.followUp;
const prompts = React.useMemo(() => {
if (followUp?.length) return followUp;
/* A definition's own suggestions come first: they are the only ones written
for this workspace rather than derived from the page, and they are capped
by the resolver so a workspace with several skills attached cannot bury
the page's own. */
const declared = owliverSuggestions(context.id, disabledSkills, customSkills, pageContext);
const skillPrompts = skills
.filter((s) => s.prompt)
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
/* Three sources can propose the same question — a skill's declared
suggestion and the page's own derived one often word it identically —
and two chips reading "Summarize hiring activity" is both a duplicate
React key and a duplicate offer. First wins, so the definition's own
wording survives and the derived copy drops out. */
/* A declared suggestion that cannot answer yet — it reads one record and
none is selected — ranks behind the page's own offers rather than
leading with a question. The lifecycle reads correctly either way:
before a position exists the page's actions lead; once one is open or
has just been created, the readings about it come first. */
const ready = declared.filter((c) => !c.deferred);
const asking = declared.filter((c) => c.deferred);
/**
* One chip per *intent*, not per wording.
*
* De-duplicating on the label alone let two chips through whenever the same
* answer was worded twice — a skill's "Show hiring activity" and its
* "Summarize hiring activity" both resolved to that skill's `summary`
* capability, so the reader was offered the same reading under two names and
* had no way to tell them apart. What a chip *resolves to* is the thing that
* must be unique: a skill capability, the page capability, or, for a chip
* that is neither, the question it sends. The label is compared too, so two
* differently-routed chips still cannot arrive reading identically.
*/
const seen = new Set();
const intentOf = (chip) => {
if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`;
if (chip?.capability) return `page:${chip.capability}`;
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
};
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
.filter((chip) => {
const label = String(chip?.label ?? '').trim().toLowerCase();
if (!label) return false;
const intent = intentOf(chip);
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
seen.add(intent);
seen.add(`label:${label}`);
return true;
});
/* `pageContext` decides which suggestions can answer without asking, so the
chips re-rank when a position is opened or closed. */
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext]);
/* 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) => {
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 });
}, [busy, send]);
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
/**
* 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, null, panelRequest.positionId);
consumeRequest(panelRequest.id);
}, [panelRequest, busy, handleMessage, consumeRequest]);
const composer = (
);
return (
{/* Header */}
Owliver
{context.page}
{/* 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. */}
{/* 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'))}
/>
{messages.length > 0 && view === 'chat' && (
)}
{expanded
? onRestore && (
)
: onExpand && (
)}
{onClose && (
)}
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
{showBackRow && (
)}
{/* ── 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 ───────────────── */}
{/* All suggestions on the landing screen, where they teach what can be
asked. Capped once a thread exists, because from then on the vertical
space belongs to the conversation. Expanded fits more per line, so it
can afford one more.
Follow-ups are never capped: when Owliver has asked a question, its
chips *are* the answers, and hiding three of the six roles would make
the flow look broken. */}
{!busy && view === 'chat' && (
)}
{composer}
Owliver reads this page's data. Check anything you act on.