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}

))}
); } /** * 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 */}
KROW Logo

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 && (
{view === 'history' ? `${history.length} conversation${history.length === 1 ? '' : 's'}` : context.page}
)} {/* ── 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. */

{intro.title}

{intro.greeting}

{intro.description}

) : (
{messages.map((message, i) => ( {i > 0 && message.role === 'user' && } ))} {pending?.thinking && } {pending && !pending.thinking && ( )} {error && {error}}
)}
{/* ── 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.

); }