update owliver skill

This commit is contained in:
2026-08-14 17:33:16 +05:30
parent cccada9bd2
commit 4fdfb90326
58 changed files with 8015 additions and 1339 deletions

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { base44 } from '@/api/base44Client';
import { resolveAssistantContext } from './placement';
import { PageContextProvider } from './PageContext';
/**
* Window state for the Owliver panel: open/collapsed and default/expanded.
@@ -149,9 +150,12 @@ export function AssistantPanelProvider({ role, pathname, children }) {
restore: () => setExpandedState(false),
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState]);
/* The page's own selection travels beside the window state, mounted here so
both the page and the panel are inside it — a skill's data source resolves
against the record the reader has open, whichever of the two is asking. */
return (
<AssistantPanelContext.Provider value={value}>
{children}
<PageContextProvider>{children}</PageContextProvider>
</AssistantPanelContext.Provider>
);
}

View File

@@ -1,6 +1,8 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } from 'lucide-react';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
@@ -13,12 +15,171 @@ import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
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 { 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 (
<div className="animate-fade-in">
<p className="font-heading text-body font-semibold text-ink-1">No conversations yet</p>
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">
Ask Owliver something and it will be here afterwards. History is kept on this device.
</p>
</div>
);
}
return (
<div className="animate-fade-in space-y-4">
{groups.map((group) => (
<section key={group.id} aria-label={group.label}>
<h3 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{group.label}
</h3>
<ul className="space-y-1">
{group.items.map((record) => (
<li key={record.id} className="group/item relative">
<button
type="button"
onClick={() => onOpen(record)}
aria-current={record.id === currentId ? 'true' : undefined}
className={cn(
`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 pr-8 text-left transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
record.id === currentId
? 'border-krow-blue/40 bg-krow-blue-tint/60'
: 'border-transparent hover:border-border hover:bg-surface-subtle'
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{record.title}
</span>
<span className="mt-0.5 block truncate text-[10px] text-ink-4">
{[
record.page,
`${record.turns} question${record.turns === 1 ? '' : 's'}`,
new Date(record.updatedAt).toLocaleTimeString(undefined, {
hour: 'numeric', minute: '2-digit',
}),
].filter(Boolean).join(' · ')}
</span>
</span>
</button>
{/* Destructive, so it stays out of the way until wanted rather
than sitting beside every row waiting to be mis-clicked. */}
<button
type="button"
onClick={() => onForget(record.id)}
aria-label={`Delete conversation: ${record.title}`}
className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 rounded p-1.5 text-ink-4 transition-colors
hover:text-destructive focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/50 group-hover/item:block group-focus-within/item:block"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</li>
))}
</ul>
</section>
))}
</div>
);
}
/**
* 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.
*
@@ -88,8 +249,50 @@ export default function KrowAssistant({
() => 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]);
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
/**
* 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
@@ -111,8 +314,8 @@ export default function KrowAssistant({
* for a skill whose file does not, and then nothing is written.
*/
const createJob = useCreateJobPosting();
const createPosition = React.useCallback(async (draft, skill) => {
const result = runAction('create_position', { draft, skill });
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]);
@@ -125,10 +328,13 @@ export default function KrowAssistant({
* routing layer because this is where the app's data already is; routing stays
* pure and testable.
*/
const { data: assignments = [] } = useAssignments();
const workforce = React.useMemo(() => ({
positions: facts.postings || [],
currentPositionId: null,
/* 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 || [],
@@ -136,7 +342,8 @@ export default function KrowAssistant({
courses: facts.forge?.library || [],
staff: facts.staff || [],
},
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments]);
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments,
pageContext.position?.id]);
/**
* The workforce write, through the mutation the app already has.
@@ -169,9 +376,13 @@ export default function KrowAssistant({
[markInterviewReady]
);
const { messages, pending, error, busy, send, stop, reset } = useConversation({
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,
@@ -183,6 +394,7 @@ export default function KrowAssistant({
roles,
skillCategories,
courses: facts.forge?.library || [],
skillContext,
});
/* A block inside an answer asking the next question, in place. Same entry
@@ -192,10 +404,37 @@ export default function KrowAssistant({
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(
@@ -208,19 +447,46 @@ export default function KrowAssistant({
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 }));
return [...skillPrompts, ...buildPrompts(context.id, facts, workforce)];
}, [followUp, skills, context.id, facts, workforce]);
/* 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);
const seen = new Set();
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
.filter((chip) => {
const key = String(chip?.label ?? '').trim().toLowerCase();
if (!key || seen.has(key)) return false;
seen.add(key);
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(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, pending]);
pinToBottom();
}, [messages, pending, pinToBottom]);
/**
* The one entry point for every message, whatever raised it.
@@ -233,6 +499,9 @@ export default function KrowAssistant({
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 });
}, [busy, send]);
@@ -271,7 +540,7 @@ export default function KrowAssistant({
padding="none"
elevation="sm"
data-wide={expanded || undefined}
className={cn('flex flex-col overflow-hidden', className)}
className={cn('relative flex flex-col overflow-hidden', className)}
role="region"
aria-label="Owliver assistant"
>
@@ -297,7 +566,19 @@ export default function KrowAssistant({
size="sm"
onClick={() => setAddSkillOpen(true)}
/>
{messages.length > 0 && (
{/* 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. */}
<IconButton
icon={History}
label={view === 'history' ? 'Back to the conversation' : 'Owliver history'}
variant="ghost"
size="sm"
aria-pressed={view === 'history'}
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
/>
{messages.length > 0 && view === 'chat' && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
{expanded
@@ -313,14 +594,53 @@ export default function KrowAssistant({
</div>
</div>
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
{showBackRow && (
<div
className={cn(
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
transition-all duration-200 ease-out motion-reduce:transition-none`,
backVisible
? 'translate-y-0 opacity-100 pointer-events-auto'
: '-translate-y-full opacity-0 pointer-events-none'
)}
aria-hidden={backVisible ? undefined : 'true'}
>
<button
type="button"
onClick={goHome}
tabIndex={backVisible ? undefined : -1}
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
>
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
<span>Back to Home</span>
</button>
<span className="truncate text-[11px] font-medium text-ink-3">
{view === 'history'
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
: context.page}
</span>
</div>
)}
{/* ── Body: the only region that scrolls ─────────────────────────── */}
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5"
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-3.5"
aria-live="polite"
aria-relevant="additions text"
>
{isEmpty ? (
{view === 'history' ? (
<HistoryView
groups={historyGroups}
currentId={conversationId}
onOpen={openHistoryItem}
onForget={forgetConversation}
/>
) : 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
@@ -373,7 +693,7 @@ export default function KrowAssistant({
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 && (
{!busy && view === 'chat' && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}

View File

@@ -0,0 +1,154 @@
import * as React from 'react';
/**
* What the page is currently looking at, published to the panel beside it.
*
* A skill's data source declares what it needs — a position, a candidate, the
* position being specified — and the page is the only thing that knows which
* one is open. Without this, Owliver could read the collections but not the
* *selection*: it would answer about the workspace when it was asked about the
* role in the drawer.
*
* The channel is deliberately narrow. A page publishes records it already has;
* it does not publish components, callbacks or view state, and nothing here
* reaches back into the page. That keeps the coupling one-way — a page that
* publishes nothing simply has no selection, and every skill whose source needs
* one says so instead of guessing.
*
* usePublishPageContext({ position }); // in the page
* const { position } = usePageContext(); // in the panel
*
* Kept out of `AssistantPanelContext` on purpose: that context is the panel's
* window state, changing when someone drags a divider. This changes when the
* reader opens a record, and merging the two would re-render one for the other.
*/
const PageContext = React.createContext(null);
export function PageContextProvider({ children }) {
const [entity, setEntity] = React.useState(null);
/**
* The writes a page is willing to accept, keyed by data source.
*
* The read channel above is serialized state; this cannot be — a handler is a
* function, closing over the page's own setter. So it lives in a ref, and only
* the *set of source ids* is state. That distinction is the whole design:
* consumers re-render when a page starts or stops accepting a write, not every
* time the page re-renders and hands over a new closure.
*
* Keyed by source id rather than by an invented action name, because the
* source is already the validated binding between a definition and its data.
* A skill therefore cannot write anywhere it could not read, and a page cannot
* accept a write for a reading it does not own.
*/
const handlersRef = React.useRef({});
const [writable, setWritable] = React.useState([]);
const publishActions = React.useCallback((handlers) => {
handlersRef.current = handlers || {};
const next = Object.keys(handlersRef.current).sort();
/* Only a change in *what* is writable is worth a render. */
setWritable((prev) => (prev.join('|') === next.join('|') ? prev : next));
}, []);
/**
* Runs a page's handler for a source. Returns false when the page is not
* accepting that write, so a caller can stay read-only rather than pretending
* the change landed.
*/
const runAction = React.useCallback((source, payload) => {
const handler = handlersRef.current[source];
if (typeof handler !== 'function') return false;
handler(payload);
return true;
}, []);
const value = React.useMemo(
() => ({ entity, setEntity, publishActions, runAction, writable }),
[entity, publishActions, runAction, writable]
);
return <PageContext.Provider value={value}>{children}</PageContext.Provider>;
}
/**
* Publishes this page's current selection for as long as it is mounted.
*
* Keyed on the content rather than the object, because a page rebuilds its
* records on every render and an identity-keyed effect would publish in a loop.
* Unmounting clears the selection, so leaving a page cannot leave Owliver
* answering about a record nobody is looking at.
*/
export function usePublishPageContext(entity) {
const context = React.useContext(PageContext);
const setEntity = context?.setEntity;
/* `undefined` values would vanish from the serialized key, so a selection
being cleared has to read as an explicit null. */
const key = React.useMemo(() => {
try {
return entity ? JSON.stringify(entity) : null;
} catch {
return null;
}
}, [entity]);
React.useEffect(() => {
if (!setEntity) return undefined;
setEntity(key ? JSON.parse(key) : null);
return () => setEntity(null);
}, [key, setEntity]);
}
/**
* The records the page has published. Always an object, so a consumer can read
* `position` without checking whether anything published at all.
*/
export function usePageContext() {
return React.useContext(PageContext)?.entity || {};
}
/**
* Offers this page's writes for as long as it is mounted.
*
* Pass a memoized map of `{ [sourceId]: handler }`. Handlers should be stable —
* a functional `setState` closes over nothing, which is what lets the map be
* memoized once rather than rebuilt as the form is typed into.
*
* const applyWeights = useCallback(
* (next) => setForm((f) => ({ ...f, vetting_criteria: next })), []);
* usePublishPageActions(useMemo(
* () => ({ 'position.vetting': applyWeights }), [applyWeights]));
*
* Unpublished on unmount, so leaving a page cannot leave a control on screen
* that writes into a form nobody is looking at.
*/
export function usePublishPageActions(handlers) {
const publishActions = React.useContext(PageContext)?.publishActions;
React.useEffect(() => {
if (!publishActions) return undefined;
publishActions(handlers);
return () => publishActions(null);
}, [publishActions, handlers]);
}
/**
* How a section writes back, for one source — or `null` when nothing on this
* page is accepting that write.
*
* Returning null rather than a no-op is deliberate: a renderer checks it to
* decide whether to draw controls at all, so a section declared `editable` on a
* page that does not own the data stays an honest read-out.
*/
export function usePageAction(source) {
const context = React.useContext(PageContext);
const accepted = Boolean(source) && (context?.writable || []).includes(source);
const runAction = context?.runAction;
return React.useMemo(
() => (accepted && runAction ? (payload) => runAction(source, payload) : null),
[accepted, runAction, source]
);
}

View File

@@ -44,7 +44,10 @@ export function PromptChips({ prompts = [], onSelect, max, align = 'center', cla
>
{visible.map((prompt, i) => (
<button
key={prompt.label}
/* Position, not label. Callers de-duplicate, but a follow-up list is
built per answer and two chips can legitimately read the same —
the key has to stay unique either way. */
key={`${i}:${prompt.label}`}
ref={(el) => { chipRefs.current[i] = el; }}
type="button"
onClick={() => onSelect(prompt)}

View File

@@ -6,6 +6,10 @@ import {
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { usePageAction } from './PageContext';
/**
* Renderers for response blocks.
@@ -441,8 +445,55 @@ const NoteBlock = React.memo(({ block }) => (
));
NoteBlock.displayName = 'NoteBlock';
/**
* A section a skill declared, drawn by the component the page uses for it.
*
* There is no chat-specific renderer for a skill's shapes, and there must not
* be: `SECTION_COMPONENTS` is the single table from a declared type to a
* component, and this block goes through it exactly as `SkillSurface` does. A
* definition that gains a new shape gains it in both places at once, and a
* shape the table does not carry renders nothing rather than something
* improvised.
*/
const SkillSectionBlock = React.memo(({ block }) => {
const section = block.section;
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
/**
* A settable answer is re-read, never replayed.
*
* An ordinary answer is a record of what was true when it was given, and the
* stored blocks are exactly right for that. A section offering *controls* is
* different: it is a live view of a value the reader can still change, from
* here or on the page behind the panel. Replaying the stored copy would show
* a weight of 25% next to a form that now says 30%, and the control would
* write the stale figure back.
*
* So an editable section resolves from the current context on every render,
* through the same resolver the page's own card uses. There is one value and
* one place it lives; this is a second window onto it, not a second copy.
*/
const live = Boolean(section?.editable);
const context = useSkillDataContext(null);
const data = React.useMemo(
() => (live && section ? resolveSkillData(section, context) : block.data),
[live, section, context, block.data]
);
const apply = usePageAction(live ? section?.source : null);
if (!Component) return null;
return (
<div className="rounded-xl border border-border bg-surface-subtle/60 p-3">
<Component data={data} section={section} onApply={apply} compact />
</div>
);
});
SkillSectionBlock.displayName = 'SkillSectionBlock';
const RENDERERS = {
text: TextBlock,
skillSection: SkillSectionBlock,
heading: HeadingBlock,
kpis: KpisBlock,
status: StatusBlock,

View File

@@ -72,6 +72,21 @@ export const badges = (items) =>
/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
export const timeline = (items) => items?.length && { type: 'timeline', items };
/**
* A section a skill definition declared, drawn in the panel.
*
* The one block whose shape is not decided here: `section` is the normalized
* record the registry produced from a `.md` file, and `data` is what the shared
* resolver read for it. The renderer hands both to the same component the page
* mounts, so a flow asked for in conversation *is* the page's flow rather than
* a chat-shaped imitation of it.
*
* Both halves are plain data, so a reply containing one still serializes into
* the stored thread like every other block.
*/
export const skillSection = (section, data) =>
section && data && { type: 'skillSection', section, data };
/** A caveat. Always the last word on a claim, never the headline. */
export const note = (value) => value && { type: 'note', text: value };

View File

@@ -1,5 +1,5 @@
import {
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, Filter, Gauge,
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, FileText, Filter, Gauge,
GitBranch, Layers, Lightbulb, Lock, ScrollText, ShieldAlert, ShieldCheck, Star, Target,
TrendingUp, UserCheck, Users,
} from 'lucide-react';
@@ -962,6 +962,41 @@ const positionsNeedingAttention = (f) => {
);
};
/**
* Positions that were saved but never published.
*
* A draft is a real record with real values on it and no way for anyone to
* apply to it, which makes it the one thing on this page that is genuinely
* unfinished. Each one is offered with the control that finishes it: the same
* authoring form, told which draft it is continuing, with everything already
* entered still in place.
*
* Read from `postings` by status — no list of drafts is kept anywhere, and a
* position stops appearing here the moment it is published.
*/
const positionDrafts = (f) => {
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
if (!drafts.length) {
return answer('Nothing is sitting in draft — every position in the workspace has been published or closed.');
}
return doc(
heading(`${plural(drafts.length, 'position')} still in draft`, 'Saved, but not published'),
insights(drafts.map((p) => ({
tone: 'warning',
title: p.title || 'Untitled position',
body: [
p.company,
p.location,
'Not published yet, so nobody can apply and no screening runs.',
].filter(Boolean).join(' · '),
action: { label: 'Continue to save', to: `/admin/positions/new?draft=${encodeURIComponent(p.id)}` },
}))),
note('Continuing reopens the same form with everything already entered — the vetting weights included. Publishing it there is what makes it an active position.')
);
};
/** Which role to fill first. */
const hiringPriority = (f) => {
const rows = f.byRole
@@ -1259,6 +1294,7 @@ export const respondControlCenter = (question, f) => {
export const respondPositions = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'draft', 'unfinished', 'not published', 'continue')) return positionDrafts(f);
if (has(q, 'attention', 'urgent', 'problem', 'wrong', 'risk')) return positionsNeedingAttention(f);
if (has(q, 'first', 'priorit', 'which position', 'should i hire')) return hiringPriority(f);
if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion')) return pipelineHealth(f);
@@ -1317,6 +1353,7 @@ export const CONTROL_CENTER_CAPABILITIES = [
];
export const POSITIONS_CAPABILITIES = [
{ id: 'position-drafts', label: 'Unfinished Drafts', icon: FileText, run: positionDrafts },
{ id: 'position-strength', label: 'Pipeline Strength', icon: TrendingUp, run: positionStrength },
{ id: 'positions-attention', label: 'Needs Attention', icon: BellRing, run: positionsNeedingAttention },
{ id: 'hiring-priority', label: 'What To Fill First', icon: Target, run: hiringPriority },
@@ -1935,3 +1972,164 @@ export const PROFILE_CAPABILITIES = [
{ id: 'profile-activity', label: 'My Recent Activity', icon: ScrollText, run: profileActivity },
{ id: 'profile-actions', label: 'What I Can Do Here', icon: Lightbulb, run: profileActions },
];
/* ── Create Position ─────────────────────────────────────────────────────
The authoring surface. Every answer here is about the role being specified,
read against what this workspace has already posted — so the guidance is the
deployment's own history rather than an industry number written into a file. */
/** The average of a numeric field across postings that state it. */
const across = (postings, read) => {
const values = postings.map(read).filter((n) => Number.isFinite(n) && n > 0);
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
};
/**
* What the five vetting criteria mean, and how this workspace usually weights
* them.
*
* The typical column is counted from the positions already posted, so it says
* what this operator does rather than what a template suggests.
*/
const vettingWeights = (f) => {
const posted = f.postings.filter((p) => p.vetting_criteria);
const keys = ['experience', 'english', 'reliability', 'certifications', 'availability'];
const meaning = {
experience: 'Years in the role, weighted against the minimum this position asks for',
english: 'Spoken level, checked in the AI interview',
reliability: 'Attendance and completion history on previous assignments',
certifications: 'Whether the credentials this role requires are held and current',
availability: 'How much of the shift pattern the candidate can actually cover',
};
if (!posted.length) {
return doc(
heading('AI vetting weights', 'What each criterion scores'),
list(keys.map((k) => `**${CRITERIA_LABELS[k]}** — ${meaning[k]}`)),
note('The five must total 100%. No position has been posted yet, so there is no house pattern to compare against.')
);
}
const typical = keys.map((key) => ({
criterion: CRITERIA_LABELS[key],
typical: `${across(posted, (p) => Number(p.vetting_criteria?.[key]))}%`,
scores: meaning[key],
}));
return doc(
heading('AI vetting weights', `Typical across ${plural(posted.length, 'posted position')}`),
table(
[
{ key: 'criterion', label: 'Criterion' },
{ key: 'typical', label: 'Usually', align: 'right' },
{ key: 'scores', label: 'What it scores' },
],
typical
),
note('These are this workspace’s own averages, not a recommendation. The five weights on this form must total 100%.')
);
};
/**
* What comparable roles in this workspace ask for.
*
* Grouped by role category, which is the only comparison the data supports —
* pay and experience are not transferable between a chef and a security post.
*/
const positionBenchmarks = (f) => {
const posted = f.postings.filter((p) => p.role_category);
if (!posted.length) {
return doc(
text('No positions have been posted yet, so there is nothing to benchmark this one against.'),
note('Once a few roles exist I can compare pay, experience and requirements by category.')
);
}
const byCategory = new Map();
posted.forEach((p) => {
const entry = byCategory.get(p.role_category) || { category: p.role_category, list: [] };
entry.list.push(p);
byCategory.set(p.role_category, entry);
});
const rows = [...byCategory.values()]
.sort((a, b) => b.list.length - a.list.length)
.slice(0, 6)
.map(({ category, list: roles }) => ({
category,
posted: roles.length,
pay: across(roles, (p) => Number(p.pay_range_min))
? `$${across(roles, (p) => Number(p.pay_range_min))}–${across(roles, (p) => Number(p.pay_range_max))}/hr`
: '—',
experience: `${across(roles, (p) => Number(p.min_experience_years))} yrs`,
}));
return doc(
heading('What comparable roles ask for', `${plural(posted.length, 'position')} on file`),
table(
[
{ key: 'category', label: 'Category' },
{ key: 'posted', label: 'Posted', align: 'right' },
{ key: 'pay', label: 'Pay', align: 'right' },
{ key: 'experience', label: 'Min experience', align: 'right' },
],
rows
),
note('Averages across positions already posted in this workspace.')
);
};
/** The credentials this workspace actually asks for, ranked by how often. */
const positionRequirements = (f) => {
const counts = new Map();
f.postings.forEach((p) => {
(p.certifications_required || []).forEach((c) => counts.set(c, (counts.get(c) || 0) + 1));
});
if (!counts.size) {
return doc(
text('No position on file requires a certification, so there is no pattern to follow here.'),
note('Add only the credentials this role genuinely needs — every one narrows the pool.')
);
}
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
return doc(
heading('Credentials this workspace asks for', `${plural(counts.size, 'certification')} in use`),
meters(ranked.map(([label, count]) => ({
label,
value: count,
max: f.postings.length || count,
}))),
note('Ranked by how many positions require them. Each requirement narrows the pool it is added to.')
);
};
/** How the form's own steps fit together, for someone new to it. */
const positionSpecSteps = () => doc(
heading('Specifying a position'),
list([
'Role category and job title — what the matching engine files this under',
'Client, requirements and pay — what the posting states',
'Certifications and required skills — what a person must hold',
'Generate Job Description with AI — writes the prose from the fields above',
'AI vetting weights — how the screening score is composed, totalling 100%',
], { ordered: true }),
note('Nothing is posted until you choose Save as Draft or Publish.')
);
export const respondCreatePosition = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'weight', 'vetting', 'criteria', 'scoring', 'score')) return vettingWeights(f);
if (has(q, 'certification', 'credential', 'requirement', 'skill')) return positionRequirements(f);
if (has(q, 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience', 'typical')) return positionBenchmarks(f);
if (has(q, 'how do i', 'step', 'what do i', 'fill', 'form', 'explain')) return positionSpecSteps(f);
return positionSpecSteps(f);
};
export const CREATE_POSITION_CAPABILITIES = [
{ id: 'vetting-weights', label: 'Explain Vetting Weights', icon: Gauge, run: vettingWeights },
{ id: 'position-benchmarks', label: 'Compare With Similar Roles', icon: ArrowLeftRight, run: positionBenchmarks },
{ id: 'position-requirements', label: 'Credentials In Use', icon: ShieldCheck, run: positionRequirements },
{ id: 'position-spec-steps', label: 'How This Form Works', icon: Lightbulb, run: positionSpecSteps },
];

View File

@@ -4,11 +4,13 @@ import {
} from './capabilities/employer';
import {
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, FORGE_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, CREATE_POSITION_CAPABILITIES,
FORGE_CAPABILITIES,
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
TALENT_POOL_CAPABILITIES,
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
respondControlCenter, respondForge, respondHiredHistory, respondPositions,
respondControlCenter, respondCreatePosition, respondForge, respondHiredHistory,
respondPositions,
respondProfile, respondTalentPool,
} from './capabilities/admin';
@@ -61,6 +63,19 @@ export const ASSISTANT_CONTEXTS = {
capabilities: POSITIONS_CAPABILITIES,
respond: respondPositions,
},
/* Specifying a role is a different question from managing the ones that
exist, so Create Position is its own context rather than Positions with a
form on it. Its topics are the form's own fields. */
'admin.createPosition': {
id: 'admin.createPosition',
page: 'Create Position',
topics: ['weight', 'vetting', 'criteria', 'scoring', 'score', 'certification', 'credential',
'requirement', 'skill', 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience',
'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i',
'what do i', 'explain', 'summar', 'flow'],
capabilities: CREATE_POSITION_CAPABILITIES,
respond: respondCreatePosition,
},
/* The Candidates list and Candidates Analysis are separate contexts because they
ask different questions of the same records: the list is triage — who needs a
decision — and Analysis reads the pool as a whole. */

View File

@@ -85,6 +85,19 @@ const GREETINGS = {
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
},
/* The form is the page, so the greeting is about what is being specified
rather than about the board — read from what this workspace has posted. */
'admin.createPosition': (f) => {
if (!f.postings.length) {
return 'This is the first position in the workspace — the weights you set here become the house pattern.';
}
/* Every posted role is a benchmark for pay and experience; only the ones
carrying weights can be compared on those, so the two are counted
separately rather than one standing in for the other. */
const weighted = f.postings.filter((p) => p.vetting_criteria).length;
return `Specifying a new role. ${plural(f.postings.length, 'position')} on file to benchmark against${weighted ? `, ${weighted} with vetting weights set` : ''}.`;
},
'admin.positions': (f) => {
if (!f.openPositions.length) return 'No open positions yet — publish a role and I will start tracking it.';
if (f.starvedPositions.length) {
@@ -211,6 +224,7 @@ const TITLES = {
'employer.analytics': () => 'Analytics assistant',
'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.createPosition': () => 'Specifying a position',
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
@@ -231,6 +245,8 @@ const DESCRIPTIONS = {
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`,
'admin.positions': (f) =>
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — hiring health, pipeline strength and what needs intervention.`,
'admin.createPosition': (f) =>
`Reading this draft against ${plural(f.postings.length, 'posted position')} — vetting weights, pay, experience and the credentials already in use.`,
'admin.candidatesList': (f) =>
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
'admin.candidates': (f) =>
@@ -281,6 +297,11 @@ const PLACEHOLDERS = {
'Ask Owliver which role to fill first…',
'Ask Owliver about hiring bottlenecks…',
],
'admin.createPosition': () => [
'Ask Owliver about the vetting weights…',
'Ask Owliver what comparable roles ask for…',
'Ask Owliver to explain this form…',
],
'admin.candidatesList': () => [
'Ask Owliver about candidates…',
'Ask Owliver who is interview ready…',
@@ -391,6 +412,15 @@ const PROMPTS = {
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
],
/* Create Position asks about the draft in front of the author. Every one is
answerable from the form and the positions already posted. */
'admin.createPosition': () => [
{ label: 'Explain the vetting weights', prompt: 'Summarize these vetting weights', capability: 'vetting-weights' },
{ label: 'Compare with similar roles', prompt: 'What do comparable roles ask for?', capability: 'position-benchmarks' },
{ label: 'Credentials in use', prompt: 'Which certifications does this workspace require?', capability: 'position-requirements' },
{ label: 'How this form works', prompt: 'Explain how this form works', capability: 'position-spec-steps' },
],
/**
* Positions suggestions are the current workforce situation, phrased as
* questions.
@@ -407,6 +437,40 @@ const PROMPTS = {
'admin.positions': (f, workforce) => {
const out = [];
/**
* Two things about the position in front of the reader come first, because
* both are about what they were *just doing* rather than about the board.
*
* A draft is unfinished work, so finishing it leads. Then the role the page
* has open — the drawer, or the one just published, which the form names in
* the URL on its way here — gets the question its state actually invites:
* a role with no applicants yet is a matching question, not a pipeline one.
*
* Both name the record, so the answer resolves against that position and
* cannot be a stale reading of a different one.
*/
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
if (drafts.length) {
out.push({
label: drafts.length === 1 ? `Continue the ${drafts[0].title} draft` : `${drafts.length} drafts to finish`,
prompt: 'Which positions are still in draft?',
capability: 'position-drafts',
});
}
const current = workforce?.currentPositionId
? (workforce.positions || []).find((p) => p.id === workforce.currentPositionId)
: null;
if (current && current.status !== 'draft') {
out.push({
label: `Find matching candidates for ${current.title}`,
/* The definition's own words, with the role named. It resolves to the
candidate-matching skill and reads that position — so switching the
skill off in Settings takes this answer with it, as it should. */
prompt: `Find matching candidates for ${current.title}`,
});
}
if (workforce?.positions?.length) {
const { positions, context } = workforce;
const ranked = prioritise(positions, context);

View File

@@ -0,0 +1,152 @@
/**
* Owliver's conversation history.
*
* The panel already persisted the *current* thread per page, in sessionStorage,
* so returning to Candidates found the conversation you left there. What it had
* no notion of was a conversation you had *finished*: starting a new one
* overwrote the old one, and there was no way back to anything.
*
* This is that archive, and it is deliberately the same artefact — a list of
* threads in the shape the panel already stores. Two differences, both
* intentional:
*
* - **localStorage, not session.** A history that empties when the tab closes
* is not a history. Threads outlive the session, which is what makes
* "Yesterday" and "Older" mean anything.
* - **Written continuously, not on reset.** The archive is updated as the
* conversation grows rather than when it ends, so a thread is recoverable
* even if the tab is closed mid-answer.
*
* Nothing here is a second conversation store: the live thread is still the one
* the panel renders from. This holds copies, keyed by conversation, for the one
* question the panel could not previously answer — "what did I ask before?"
*/
const KEY = 'krow_assistant:history';
/** Threads older than this stop being useful and start being clutter. */
const MAX_AGE_DAYS = 60;
/** A hard cap, so a heavy user cannot fill the origin's storage quota. */
const MAX_RECORDS = 100;
const DAY = 24 * 60 * 60 * 1000;
/** Every archived conversation, newest first. Never throws. */
export function readHistory() {
try {
const raw = localStorage.getItem(KEY);
const list = raw ? JSON.parse(raw) : [];
if (!Array.isArray(list)) return [];
const cutoff = Date.now() - MAX_AGE_DAYS * DAY;
return list
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
} catch {
/* Corrupt or unavailable storage is an empty history, not an error the
reader has to deal with. */
return [];
}
}
/** The first thing the person asked, which is what the thread was about. */
function titleFor(messages) {
const first = messages.find((m) => m.role === 'user' && m.text);
const text = String(first?.text || '').trim();
if (!text) return 'Conversation';
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
}
/**
* Records this conversation, replacing its own earlier entry.
*
* Matched on `id`, so a thread being added to updates in place rather than
* appearing once per turn.
*/
export function saveConversation({ id, contextId, page, messages }) {
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
const record = {
id,
contextId,
page: page || '',
title: titleFor(messages),
turns: messages.filter((m) => m.role === 'user').length,
updatedAt: new Date().toISOString(),
messages,
};
const next = [record, ...readHistory().filter((r) => r.id !== id)].slice(0, MAX_RECORDS);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* Quota or private mode: the live thread is unaffected, only the archive
is. Dropping the oldest half is a better outcome than losing every
future write. */
try {
localStorage.setItem(KEY, JSON.stringify(next.slice(0, Math.floor(MAX_RECORDS / 4))));
} catch {
/* Storage is unusable. History is in-memory for this session only. */
}
}
return next;
}
/** Forgets one conversation. */
export function removeConversation(id) {
const next = readHistory().filter((r) => r.id !== id);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* Ignore — the caller re-reads either way. */
}
return next;
}
/** Forgets everything. */
export function clearHistory() {
try {
localStorage.removeItem(KEY);
} catch {
/* Ignore. */
}
return [];
}
/**
* Conversations under a heading, in the order a person thinks about time.
*
* Empty groups are dropped rather than rendered as a heading with nothing
* under it.
*/
export function groupByRecency(records, now = new Date()) {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const startOfYesterday = new Date(startOfToday.getTime() - DAY);
const startOfWeek = new Date(startOfToday.getTime() - 7 * DAY);
const groups = [
{ id: 'today', label: 'Today', items: [] },
{ id: 'yesterday', label: 'Yesterday', items: [] },
{ id: 'week', label: 'Earlier this week', items: [] },
{ id: 'older', label: 'Older', items: [] },
];
for (const record of records) {
const at = new Date(record.updatedAt || 0).getTime();
if (at >= startOfToday.getTime()) groups[0].items.push(record);
else if (at >= startOfYesterday.getTime()) groups[1].items.push(record);
else if (at >= startOfWeek.getTime()) groups[2].items.push(record);
else groups[3].items.push(record);
}
return groups.filter((g) => g.items.length);
}
/** A new conversation id. Time-ordered, so ids sort the way threads do. */
export function newConversationId() {
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}

View File

@@ -21,6 +21,9 @@
*/
export { AssistantPanelProvider, useAssistantPanel } from './AssistantPanelContext';
export {
usePageAction, usePageContext, usePublishPageActions, usePublishPageContext,
} from './PageContext';
export { AssistantTrigger } from './AssistantTrigger';
export { AssistantPanel } from './AssistantPanel';
export { default as KrowAssistant } from './KrowAssistant';

View File

@@ -15,6 +15,11 @@ const PLACEMENT = {
admin: {
'/admin': 'admin.controlCenter',
'/admin/positions': 'admin.positions',
/* The authoring form. Owliver belongs here for the same reason it belongs
on Positions: the questions asked while specifying a role — what do these
weights score, what do comparable roles ask for — are answerable from the
page and from what this workspace has already posted. */
'/admin/positions/new': 'admin.createPosition',
'/admin/candidates': 'admin.candidatesList',
'/admin/candidates-analysis': 'admin.candidates',
'/admin/analytics': 'admin.analytics',
@@ -40,6 +45,8 @@ const PLACEMENT = {
export const EXCLUDED_ROUTES = [
// Every Employer and Talent route: the contextual panel is Admin-only.
'/', '/overview', '/positions', '/positions/new', '/positions/:id', '/candidates',
/* The Employer aliases only — the Admin `/admin/positions/new` carries the
panel, and is listed in the placement table above. */
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system',

View File

@@ -1,7 +1,12 @@
import { doc, insights, list, note, text } from './blocks';
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
import { ASSISTANT_CONTEXTS } from './contexts';
import { poolFor } from '@/lib/workforce';
import { matchSkill } from '@/lib/skills/registry';
import { owliverCapabilityLabel } from '@/lib/skills/surfaces';
import {
matchOwliverSkill, owliverSkillsForContext, presentable, resolveOwliverResponse,
responseTitle, summaryLines,
} from '@/lib/skills/owliverResolver';
import {
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
@@ -472,6 +477,96 @@ function proposeTraining(skill, question, courses, skillCategories) {
};
}
/* ── Declared skills ────────────────────────────────────────────────────── */
/**
* A skill answering from its own definition.
*
* The whole reply is composed from what the `.md` declared and what the shared
* resolver read: the title is the definition's, the capability is the shape it
* offers, the figures are the application's own records, and the follow-ups are
* the other capabilities the same definition carries. Nothing in this function
* knows which skill it is serving, and nothing may be added that does — the
* moment a skill id appears here, the definition has stopped being the source
* of truth.
*/
function declaredAnswer({ skill, capability, question, skillContext }) {
const resolved = resolveOwliverResponse({ skill, capability, question, context: skillContext });
if (!resolved) return null;
const { section, data, missing } = resolved;
const title = responseTitle(skill, section);
/* Other shapes of the same reading, offered rather than explained. A chip
per remaining capability, worded from the definition's own suggestions
where it wrote one for that shape. */
const followUp = skill.owliver.capabilities
.filter((c) => c !== capability)
.map((c) => {
const written = skill.owliver.suggestions.find((s) => s.capability === c);
return {
label: written?.label || `${skill.name} as ${owliverCapabilityLabel(c).toLowerCase()}`,
prompt: written?.prompt || `Show ${skill.name} as a ${c}`,
};
})
.slice(0, 3);
/**
* The reading needs a record the page has not supplied. Say which, and offer
* the ones there are — an answer about the wrong position would be worse
* than a question.
*/
if (missing === 'position') {
const open = (skillContext.positions || []).filter((p) => p.status === 'active').slice(0, 6);
return {
kind: 'skill',
skill,
doc: doc(
text(`**${title}** reads one position. Which should I read?`),
list(open.map((p) => p.title)),
note('Open a position, or name one and I will read it.')
),
followUp: open.slice(0, 4).map((p) => ({ label: p.title, prompt: `${question} for ${p.title}` })),
};
}
if (missing === 'candidate') {
return {
kind: 'skill',
skill,
doc: doc(
text(`**${title}** reads one candidate.`),
note('Open a candidate, or name one and I will read their record.')
),
};
}
/* A reading with nothing in it says so, in the resolver's own words. No
figure is invented to fill a shape. */
if (data.unavailable || data.empty) {
return {
kind: 'skill',
skill,
doc: doc(heading(title), text(data.emptyNote || 'There is nothing to report yet.')),
followUp,
};
}
/* A summary is prose; every other capability is the section the page would
draw, drawn here. */
const body = capability === 'summary'
? [
list(summaryLines(data)),
data.total != null ? note(`${data.total} record${data.total === 1 ? '' : 's'} in total.`) : null,
]
: [skillSection(section, presentable(data))];
return {
kind: 'skill',
skill,
doc: doc(heading(title, section.description || undefined), ...body),
followUp,
};
}
/**
* Skills that can act on this page, ahead of the page's own responder.
*
@@ -480,10 +575,76 @@ function proposeTraining(skill, question, courses, skillCategories) {
* bartender position" open a form instead of returning a pipeline report that
* happens to match the word "position".
*/
function resolveSkill({ question, contextId, disabledSkills, customSkills, roles, skillCategories, courses }) {
const skill = matchSkill(question, contextId, disabledSkills, customSkills);
function resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
skillContext = null,
}) {
/**
* One question, one skill, then one way of answering it.
*
* This used to be two independent matchers run in sequence: declared skills
* were resolved first and answered immediately, and only a question that
* matched none of them reached the trigger matcher and the coded flows below.
* That ordering was the bug. A declared definition could claim a question
* belonging to a flow it knew nothing about — "create a position" was
* answered by a candidate-matching skill — because the first matcher never
* asked whether anything else had a stronger claim.
*
* So both matchers run, and *then* the question is dispatched:
*
* 1. A skill with a coded flow owns its own triggers. Create Position and
* Forge authoring are conversations with state, not readings, and no
* declaration may take one over.
* 2. Otherwise a declared answer, if the definition earned the question.
* 3. Otherwise the definition reads itself back.
*
* The property this gives is the one that matters: a skill added tomorrow
* cannot capture a flow that already exists, whatever it declares.
*/
const declared = skillContext
? matchOwliverSkill(
question,
owliverSkillsForContext(contextId, disabledSkills, customSkills)
)
: null;
const triggered = matchSkill(question, contextId, disabledSkills, customSkills);
/**
* Which of the two named the question.
*
* A trigger is an explicit claim on a phrase, so it usually names the skill.
* Two cases where it must not, both of which were live bugs:
*
* - **A verbatim suggestion outranks a trigger.** A definition that
* published "Show hiring activity" and had the reader click it has the
* strongest claim there is. A *different* definition whose trigger merely
* contains those words — including the name every definition falls back
* to when it declares no triggers — has a weaker one, and was winning.
* - **An answer outranks a description.** A skill with no declared
* capabilities can only read its own definition back. If it takes a
* question another definition can actually answer, the reader gets a
* restatement of the question instead of the figures.
*
* Neither rule names a skill: both compare what the two definitions declared.
*/
const canAnswer = (s) => Boolean(s?.owliver?.enabled && s.owliver.capabilities.length);
/* A definition that collects answers in the chat or names an action to run is
a flow, not a reading, and owns the phrases it declared — read off what it
declares rather than from a list of ids, so a flow added later is protected
by the same rule. */
const ownsFlow = (s) => Boolean(s?.conversation?.length || s?.actions?.length);
const skill = (triggered && ownsFlow(triggered) && (!declared || !declared.exact))
? triggered
: declared && (declared.exact || !canAnswer(triggered))
? declared.skill
: triggered || declared?.skill || null;
if (!skill) return null;
/* The declared answer is only offered for the skill that actually won. */
const declaredForSkill = declared && declared.skill.id === skill.id ? declared : null;
/**
* Forge authoring. Same contract as Create Position: what the sentence
* answered is filled in, the rest is left blank, and the flow opens rather
@@ -522,9 +683,19 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
}
/* A skill with no coded handler still answers, from its own definition. That
is what makes a Markdown-only skill useful on the page it names without a
line of Owliver being changed for it. */
/**
* No coded flow owns this one, so the definition answers it.
*
* A declared `owliver:` block is the richer answer — a real reading, drawn in
* the shape the author asked for — so it is tried first. A definition without
* one still reads itself back, which is what makes a Markdown-only skill
* useful on the page it names without a line of Owliver being changed for it.
*/
if (declaredForSkill) {
const answer = declaredAnswer({ ...declaredForSkill, question, skillContext });
if (answer) return answer;
}
return {
kind: 'skill',
skill,
@@ -538,13 +709,14 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [], workforce = null,
courses = [], workforce = null, skillContext = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
skillContext,
});
if (skill) return skill;

View File

@@ -6,12 +6,16 @@ import {
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { skillsForContext } from '@/lib/skills/registry';
import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
import {
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
} from '@/lib/skills/workforceFlow';
import {
newConversationId, readHistory, removeConversation, saveConversation,
} from './history';
import { buildFacts } from './insights';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
@@ -121,16 +125,35 @@ async function streamDocument({ document, signal, onFrame, onDone }) {
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [],
roles = [], skillCategories = [], courses = [], skillContext = 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 = '',
}) {
const storageKey = `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
@@ -155,6 +178,11 @@ export function useConversation({
} 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;
@@ -163,8 +191,16 @@ export function useConversation({
}
setPending(null);
setError(null);
}, [storageKey, flowKey]);
}, [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 {
@@ -172,7 +208,26 @@ export function useConversation({
} catch {
// Quota or private mode — the thread stays in memory only.
}
}, [storageKey]);
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,
}));
}, [storageKey, idKey, contextId, pageLabel]);
/* Abort any in-flight response when the context changes or we unmount. */
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
@@ -224,7 +279,7 @@ export function useConversation({
? { kind: 'answer' }
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
courses, workforce,
courses, workforce, skillContext,
});
}
@@ -236,14 +291,33 @@ export function useConversation({
if (intent.kind === 'flow' && intent.create) {
let created = null;
try {
created = await onCreatePosition?.(intent.create.draft, intent.skill);
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
} catch {
created = null;
}
if (created?.id) {
/**
* What can now be asked about the position that was just made.
*
* A live position is something the page's skills can read; a draft is
* not finished being specified, so offering to analyse it would be
* answering about a record the admin has not committed to. The registry
* decides *which* skills can say something — see
* `suggestionsForPosition` — and this decides only whether it is yet
* the moment to ask them.
*/
const ready = created.status === 'active';
intent = {
...intent, flow: null, doc: positionCreatedReply(created), followUp: createdFollowUp(created),
...intent,
flow: null,
doc: positionCreatedReply(created),
followUp: [
...createdFollowUp(created),
...(ready
? suggestionsForPosition(contextId, disabledSkills, customSkills, created)
: []),
],
};
} else {
/* Keep the answers: the summary is still there to try again from. */
@@ -372,10 +446,18 @@ export function useConversation({
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses]);
customSkills, roles, skillCategories, courses, skillContext]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);
/**
* 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 = [];
@@ -385,12 +467,47 @@ export function useConversation({
/* 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, setFlow]);
}, [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);
messagesRef.current = record.messages;
setMessages(record.messages);
conversationRef.current = record.id;
try {
sessionStorage.setItem(storageKey, JSON.stringify(record.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]);
return {
messages,
@@ -400,6 +517,12 @@ export function useConversation({
send,
stop,
reset,
/** Every archived conversation, newest first. */
history,
/** The conversation on screen, so History can mark it. */
conversationId: conversationRef.current,
openConversation,
forgetConversation,
providerId: provider.id,
};
}

View File

@@ -0,0 +1,183 @@
import React, {
createContext, useCallback, useContext, useEffect, useMemo, useRef, useState,
} from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* A reading path down a long page.
*
* The candidate profile is one scroll from "who is this" to "what should I do",
* and the sections are steps in that order rather than a pile of independent
* cards. This draws the order: a thin rail, one node per section, filled behind
* you and hollow ahead.
*
* It is navigation you read, not navigation you click — deliberately. A rail of
* links would be the tab bar again, in a thinner shape. Nothing here moves the
* page; it only says where in the evaluation you are.
*
* Built from a flex column and a border. No graph library: the flow is linear,
* and a linear flow drawn by a layout engine costs nothing to ship.
*/
const JourneyContext = createContext(null);
/** Where a section counts as "being read": a third of the way down the viewport. */
const READING_LINE = 0.35;
/**
* Holds which step is current.
*
* "Current" is the last section whose heading has crossed the reading line — not
* merely what is on screen, since three sections can be visible at once and only
* one of them is being read.
*
* A scroll listener rather than an observer: the question is "which of these
* eight is furthest past a line", which is one comparison per step against one
* number. An IntersectionObserver answers a different question — is this element
* in a box — and stitching eight of those answers back into an ordering is both
* more code and less exact at the boundaries. Reads are throttled to one per
* frame and every step is a single `getBoundingClientRect`.
*/
export function Journey({ children, className }) {
const [active, setActive] = useState(0);
const steps = useRef(new Map());
const register = useCallback((index, el) => {
if (el) steps.current.set(index, el);
else steps.current.delete(index);
}, []);
useEffect(() => {
const compute = () => {
const line = window.innerHeight * READING_LINE;
let next = 0;
steps.current.forEach((el, index) => {
if (el.getBoundingClientRect().top <= line && index > next) next = index;
});
setActive((prev) => (prev === next ? prev : next));
};
/* Straight off the scroll event, not through requestAnimationFrame: a
backgrounded tab never runs its frame callbacks, so a rAF-coalesced
version silently stops updating and comes back stuck on whichever step
was current when the tab was hidden. The work here is a handful of
`getBoundingClientRect` reads and no writes, which is well inside what a
scroll handler can afford. */
compute();
window.addEventListener('scroll', compute, { passive: true });
window.addEventListener('resize', compute);
return () => {
window.removeEventListener('scroll', compute);
window.removeEventListener('resize', compute);
};
}, []);
const value = useMemo(() => ({ active, register }), [active, register]);
return (
<JourneyContext.Provider value={value}>
<div className={className}>{children}</div>
</JourneyContext.Provider>
);
}
/**
* One step: a node, the line to the next one, a heading, and the section.
*
* The section fades up once as it arrives and never animates again — motion
* that repeats on every scroll past is a page that will not settle.
*/
export function JourneyStep({ index, label, meta, last = false, children }) {
const { active = 0, register } = useContext(JourneyContext) || {};
const reduced = useReducedMotion();
const ref = useCallback((el) => register?.(index, el), [register, index]);
const state = index < active ? 'done' : index === active ? 'active' : 'upcoming';
return (
<div ref={ref} className="relative flex gap-3.5 sm:gap-4">
{/* Rail */}
<div className="flex w-3 shrink-0 flex-col items-center pt-1" aria-hidden="true">
<span
className={cn(
'grid h-3 w-3 shrink-0 place-items-center rounded-full border-2 transition-colors duration-300',
state === 'upcoming' && 'border-border bg-surface',
state === 'active' && 'border-krow-blue bg-krow-blue ring-4 ring-krow-blue/15',
state === 'done' && 'border-krow-blue bg-krow-blue'
)}
>
{state === 'done' && <Check className="h-2 w-2 text-white" strokeWidth={4} />}
</span>
{!last && (
/* 2px with rounded caps: a hairline reads as a table border, and a
square end butting against a round node reads as a seam. */
<span className="relative mt-1.5 w-0.5 flex-1 overflow-hidden rounded-full bg-border">
{/* The travelled part of the line, drawn over the grey one so the
colour change is a height transition rather than a repaint.
A CSS transition rather than an animated value: the final height
is in the style either way, so a tab that never ran a frame —
backgrounded, or restored from one — still paints the rail in the
right state instead of holding the position it was hidden at. */}
<span
className="absolute inset-x-0 top-0 rounded-full bg-krow-blue transition-[height] duration-300 ease-out motion-reduce:transition-none"
style={{ height: state === 'done' ? '100%' : '0%' }}
/>
</span>
)}
</div>
{/* Section */}
<motion.section
className="min-w-0 flex-1 pb-7"
initial={reduced ? false : { opacity: 0, y: 12 }}
whileInView={reduced ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.12 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-4">{label}</h2>
{meta && <span className="shrink-0 text-caption text-ink-4">{meta}</span>}
</div>
{children}
</motion.section>
</div>
);
}
/**
* A number that counts up to itself once.
*
* Short and ease-out: the point is to draw the eye to the figure, not to make
* the reader wait for it. Returns the target immediately when the reader has
* asked for reduced motion, or before the element has been seen.
*/
export function useCountUp(target, { duration = 750, start = true } = {}) {
const reduced = useReducedMotion();
const [value, setValue] = useState(reduced || !start ? target : 0);
useEffect(() => {
if (reduced || !start) {
setValue(target);
return undefined;
}
let frame;
const t0 = performance.now();
const tick = (now) => {
const p = Math.min(1, (now - t0) / duration);
/* ease-out cubic — fast to nearly-there, then settles. No overshoot: a
score that bounces past itself reads as a different score. */
setValue(Math.round(target * (1 - (1 - p) ** 3)));
if (p < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target, duration, reduced, start]);
return value;
}
export default Journey;

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { ArrowRight, GraduationCap, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
@@ -39,6 +40,58 @@ const STATUS_LABEL = {
not_started: 'Not started',
};
/**
* One skill as a path: where they are verified, and the level they are walking to.
*
* The same three numbers this row always carried — level held, modules done,
* modules needed — drawn as the progression they describe rather than a comma
* list. The filled segment is `progressToNext`, so a person two modules from
* Advanced looks two modules from Advanced.
*
* A skill at its top level has no path to draw and says so.
*/
function SkillPath({ state }) {
const reduced = useReducedMotion();
const held = state.verifiedLabel;
if (!state.nextLevel) {
return <p className="mt-1.5 text-caption text-ink-3">{held} · top level on this pathway</p>;
}
return (
/* Bounded: a path stretched the width of the card reads as a progress bar
for the card, not a two-stop journey between two named levels. */
<div className="mt-2 max-w-[320px]">
<div className="flex items-center gap-1.5" aria-hidden="true">
<span
className={cn(
'h-2 w-2 shrink-0 rounded-full',
state.verifiedLevel ? 'bg-krow-blue' : 'border-2 border-border bg-surface'
)}
/>
<span className="relative h-px min-w-0 flex-1 bg-border">
<motion.span
className="absolute inset-y-0 left-0 bg-krow-blue"
initial={reduced ? false : { width: 0 }}
whileInView={{ width: `${state.progressToNext}%` }}
viewport={{ once: true, amount: 0.8 }}
transition={reduced ? { duration: 0 } : { duration: 0.7, ease: 'easeOut' }}
/>
</span>
<span className="h-2 w-2 shrink-0 rounded-full border-2 border-border bg-surface" />
</div>
<div className="mt-1 flex items-baseline justify-between gap-3 text-caption">
<span className="truncate text-ink-2">{held}</span>
<span className="shrink-0 text-ink-4">
{levelLabel(state.nextLevel)}
{state.requiredForNext > 0 && ` · ${state.completedInNext}/${state.requiredForNext}`}
</span>
</div>
</div>
);
}
export function VerifiedSkills({
pageId, profile, profiles = [], courses = [], positions = [], email, customSources = [],
onOpenTraining, className,
@@ -132,40 +185,34 @@ export function VerifiedSkills({
<div>
<ul className="divide-y divide-border rounded-xl border border-border">
{(held.length ? held : states).map((state) => (
<li key={state.skillId} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">
{state.name}
{/* The training path behind this level, named from the Forge
definition attached to this page. The pathway wording is
the level being worked towards, so a reader can see both
where the person is and what they are on. */}
{byId.get(state.skillId) && state.nextLevel && (
<span className="font-normal text-ink-4">
{' · '}{byId.get(state.skillId).name}
{' · '}{levelLabel(state.nextLevel)} pathway
</span>
<li key={state.skillId} className="px-4 py-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">
{state.name}
{/* The training path behind this level, named from the Forge
definition attached to this page. The pathway wording is
the level being worked towards, so a reader can see both
where the person is and what they are on. */}
{byId.get(state.skillId) && state.nextLevel && (
<span className="font-normal text-ink-4">
{' · '}{byId.get(state.skillId).name}
</span>
)}
</p>
</div>
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
STATUS_STYLE[state.status]
)}
</p>
<p className="truncate text-caption text-ink-3">
{state.verifiedLabel}
{state.nextLevel && state.requiredForNext > 0 && (
<span className="text-ink-4">
{' · '}{state.completedInNext}/{state.requiredForNext} towards {levelLabel(state.nextLevel)}
{' · '}{state.progressToNext}%
</span>
)}
</p>
>
{state.verifiedLevel && <ShieldCheck className="h-3 w-3" aria-hidden="true" />}
{STATUS_LABEL[state.status]}
</span>
</div>
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
STATUS_STYLE[state.status]
)}
>
{state.verifiedLevel && <ShieldCheck className="h-3 w-3" aria-hidden="true" />}
{STATUS_LABEL[state.status]}
</span>
<SkillPath state={state} />
</li>
))}
</ul>

View File

@@ -0,0 +1,401 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, ChevronRight, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ProgressBar } from '@/components/ds';
/**
* The components a skill definition may ask for, and nothing else.
*
* `type: flow` in a Markdown file resolves to `FlowSection` through the table
* at the bottom of this file. That table is the only bridge between a
* definition and a React component: a definition names a key, the renderer
* looks it up, and a key that is not here was already refused by validation.
* There is no dynamic import, no component name evaluated from a string, and
* no markup carried from the file.
*
* Every section is built from the existing KROW vocabulary — the same borders,
* blue, pills and type as the rest of Admin — so an extension looks like part
* of the product rather than like an embedded widget.
*/
/** What a section shows when its data has nothing in it. Never a fake figure. */
function EmptyNote({ children }) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
{children}
</p>
);
}
/**
* A sequence — periods, or the stages of a funnel.
*
* Built from flex and borders rather than a graph library: the shape is a row
* of steps with connectors, and a dependency for that would be heavier than the
* feature. Wraps to a column on narrow screens, where a horizontal flow would
* either overflow or become unreadable.
*/
function FlowSection({ data }) {
if (data.empty || !data.steps?.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
const peak = Math.max(...data.steps.map((s) => s.value || 0), 1);
return (
<ol className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
{data.steps.map((step, i) => (
<React.Fragment key={step.id}>
{i > 0 && (
<li aria-hidden="true" className="flex shrink-0 items-center justify-center text-ink-4">
{/* The connector turns with the layout: down when stacked. */}
<ChevronRight className="hidden h-4 w-4 sm:block" />
<span className="text-[11px] sm:hidden">↓</span>
</li>
)}
<li className="min-w-0 flex-1 rounded-xl border border-border bg-surface px-3 py-2.5">
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{step.label}</p>
<p className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">{step.value}</p>
{step.detail && <p className="text-caption text-ink-3">{step.detail}</p>}
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-surface-sunken">
<div
className="h-full rounded-full bg-krow-blue transition-all"
style={{ width: `${Math.round(((step.value || 0) / peak) * 100)}%` }}
/>
</div>
</li>
</React.Fragment>
))}
</ol>
);
}
/** Counted figures, side by side. */
function StatsSection({ data }) {
const items = data.steps || data.items || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{items.map((item) => (
<div key={item.id} className="rounded-xl border border-border bg-surface px-3 py-2.5">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{item.label || item.title}
</dt>
<dd className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">
{item.value ?? '—'}
</dd>
</div>
))}
</dl>
);
}
/** Records as rows, each optionally linking to the record it names. */
function ListSection({ data }) {
const items = data.items || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{items.map((item) => {
const body = (
<>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
{item.detail && <span className="block truncate text-caption text-ink-3">{item.detail}</span>}
</span>
{item.value != null && (
<span className="shrink-0 font-heading text-body font-bold tabular-nums text-ink-1">
{item.value}
</span>
)}
{item.to && <ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
</>
);
return (
<li key={item.id}>
{item.to ? (
<Link to={item.to} className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-surface-subtle">
{body}
</Link>
) : (
<div className="flex items-center gap-3 px-3 py-2.5">{body}</div>
)}
</li>
);
})}
</ul>
);
}
/** Dated events, most recent first. */
function TimelineSection({ data }) {
const items = [...(data.items || [])]
.filter((item) => item.at)
.sort((a, b) => new Date(b.at) - new Date(a.at));
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<ol className="space-y-2">
{items.map((item) => (
<li key={item.id} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="min-w-0">
<span className="font-medium text-ink-1">{item.title}</span>
{item.detail && <span className="text-ink-3"> — {item.detail}</span>}
</span>
<span className="shrink-0 text-caption text-ink-4">
{new Date(item.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</li>
))}
</ol>
);
}
/** Rows and columns, from whatever the source declared. */
function TableSection({ data }) {
const items = data.items || data.steps || [];
const columns = data.columns || [
{ key: 'label', label: 'Item' },
{ key: 'value', label: 'Value', align: 'right' },
];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-body-sm">
<thead>
<tr className="border-b border-border bg-surface-subtle">
{columns.map((c) => (
<th
key={c.key}
scope="col"
className={cn(
'whitespace-nowrap px-3 py-2 text-[10px] font-bold uppercase tracking-wide text-ink-4',
c.align === 'right' ? 'text-right' : 'text-left'
)}
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b border-border last:border-0">
{columns.map((c) => (
<td
key={c.key}
className={cn(
'px-3 py-2 text-ink-2',
c.align === 'right' ? 'text-right tabular-nums' : 'text-left'
)}
>
{item[c.key] ?? '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** Bars against a total. */
function ProgressSection({ data }) {
const items = data.items || data.steps || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
const peak = Math.max(...items.map((i) => i.max || i.value || 0), 1);
return (
<ul className="space-y-2.5">
{items.map((item) => {
const max = item.max || peak;
const percent = max ? Math.round(((item.value || 0) / max) * 100) : 0;
return (
<li key={item.id}>
<div className="mb-1 flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-body-sm capitalize text-ink-2">
{item.title || item.label}
</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{item.value ?? 0}{item.max ? `/${item.max}` : ''}
</span>
</div>
<ProgressBar value={percent} tone="brand" size="sm" />
</li>
);
})}
</ul>
);
}
/** One finding, stated plainly. */
function InsightSection({ data }) {
const items = data.items || data.steps || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
const lead = items.reduce((best, item) => ((item.value || 0) > (best?.value || 0) ? item : best), items[0]);
return (
<div className="flex items-start gap-2.5 rounded-xl border border-krow-blue/30 bg-krow-blue-tint/50 px-3.5 py-3">
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
<p className="text-body-sm leading-relaxed text-ink-2">
<span className="font-semibold text-ink-1">{lead.label || lead.title}</span>
{lead.value != null && <> — <span className="tabular-nums">{lead.value}</span></>}
{lead.detail && <span className="text-ink-3"> · {lead.detail}</span>}
</p>
</div>
);
}
/** A plain panel: whatever figures the source gives, as a sentence and a row. */
function CardSection({ data }) {
const items = data.steps || data.items || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<div className="rounded-xl border border-border bg-surface-subtle px-3.5 py-3">
<dl className="flex flex-wrap gap-x-6 gap-y-2">
{items.slice(0, 6).map((item) => (
<div key={item.id} className="min-w-0">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{item.label || item.title}
</dt>
<dd className="text-body-sm font-medium tabular-nums text-ink-1">{item.value ?? item.detail ?? '—'}</dd>
</div>
))}
</dl>
</div>
);
}
/**
* Weighted criteria that share one budget.
*
* Distinct from `ProgressSection`, which draws bars against independent
* maximums. Here the rows are shares of a single total, so the total is stated
* once for the set and flagged when it does not come to 100 — which is the only
* thing that makes a set of weights wrong rather than merely different.
*
* The same component draws the page card and Owliver's compact answer; `compact`
* only tightens spacing. Two renderings of one declaration, not two components.
*
* **Editing.** Controls appear only when the definition asked for them *and* the
* surface passed an `onApply` — which a page only supplies for a source it owns.
* Adjustments are held locally until Apply, so a half-dragged slider never
* writes a total of 87% into the form; Apply hands the whole set over at once
* and the page's own state is the only place it lands.
*/
function WeightsSection({ data, section, onApply, compact = false }) {
const rows = React.useMemo(() => data?.steps || data?.items || [], [data]);
const editable = Boolean(section?.editable && onApply);
/* Local only while editing, and re-seeded whenever the page's own values
change — so a change made on the form shows up here even mid-edit, and
there is never a second copy of the weights outliving the interaction. */
const [draft, setDraft] = React.useState(null);
const signature = React.useMemo(
() => rows.map((r) => `${r.id}:${r.value}`).join('|'),
[rows]
);
React.useEffect(() => { setDraft(null); }, [signature]);
if (data?.empty || !rows.length) return <EmptyNote>{data?.emptyNote}</EmptyNote>;
const current = draft || Object.fromEntries(rows.map((r) => [r.id, Number(r.value) || 0]));
const total = Object.values(current).reduce((a, b) => a + b, 0);
const balanced = total === 100;
const dirty = draft !== null && rows.some((r) => (Number(r.value) || 0) !== current[r.id]);
return (
<div className={compact ? 'space-y-2' : 'space-y-2.5'}>
<ul className={compact ? 'space-y-1.5' : 'space-y-2.5'}>
{rows.map((row) => {
const value = current[row.id] ?? 0;
return (
<li key={row.id}>
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-body-sm capitalize text-ink-2">
{row.title || row.label}
</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{value}%
</span>
</div>
{editable ? (
<input
type="range"
min="0"
max="100"
value={value}
aria-label={`${row.title || row.label} weight`}
onChange={(e) => setDraft({ ...current, [row.id]: Number(e.target.value) })}
className="mt-1 w-full accent-krow-blue"
/>
) : (
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<div
className="h-full rounded-full bg-krow-blue transition-all"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
)}
</li>
);
})}
</ul>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-2">
<span className="text-caption text-ink-3">
Total{' '}
<span
className={cn(
'font-semibold tabular-nums',
balanced ? 'text-success' : 'text-warning'
)}
>
{total}%
</span>
{!balanced && <span className="text-ink-4"> — must total 100%</span>}
</span>
{editable && (
<button
type="button"
disabled={!dirty || !balanced}
onClick={() => { onApply(current); setDraft(null); }}
className="inline-flex items-center rounded-lg bg-krow-blue px-2.5 py-1 text-caption font-semibold text-white
transition-colors hover:bg-krow-blue-strong disabled:cursor-not-allowed disabled:opacity-40
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Apply to position
</button>
)}
</div>
</div>
);
}
/**
* The registry: declared type → component.
*
* The only mapping from a definition to rendering. Adding a type means adding a
* component here and a name in `surfaces.js` — never a branch on a skill id.
*/
export const SECTION_COMPONENTS = {
card: CardSection,
stats: StatsSection,
list: ListSection,
timeline: TimelineSection,
flow: FlowSection,
table: TableSection,
progress: ProgressSection,
insight: InsightSection,
weights: WeightsSection,
};

View File

@@ -0,0 +1,181 @@
import React, { useMemo } from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
useApplications, useAssignments, useCourses, useCurrentUser, useInterviews, useJobPostings,
usePreferences, useStaff, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { allSkills } from '@/lib/skills/registry';
import { sectionsForPage } from '@/lib/skills/uiConfig';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
/* The page's current selection, from the one channel that carries it. Imported
from the module rather than the package index so a page section never pulls
the assistant panel in behind it. */
import { usePageAction, usePageContext } from '@/components/ai-assistant/PageContext';
/**
* The extension point: one controlled slot a page offers to skills.
*
* A page says where a skill *may* appear; a skill definition says where it
* *does*. Neither knows the other exists — the page names a surface and a
* placement, the definition names the same two things, and this component is
* what joins them. That is why a new skill needs no change to any page, and why
* a page can be redesigned without touching a skill.
*
* <SkillSurface page="positions" placement="after-position-summary"
* context={{ position }} />
*
* What it will not do is as important as what it does. It renders only sections
* that survived validation, only from skills that are active, only through the
* component table, and only with data the resolver produced from real records.
* Nothing from the Markdown reaches the DOM as markup.
*/
/** The skills contributing sections to this page right now. */
function useSkillSections(page, placement) {
const preferences = usePreferences();
const customKey = JSON.stringify(preferences.customSkills || []);
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
return useMemo(() => {
const custom = JSON.parse(customKey);
const disabled = JSON.parse(disabledKey);
return allSkills(custom)
/* Inactive means registered but not offered — the same rule the assistant
follows, so switching a skill off removes its UI too. */
.filter((skill) => skill.status === 'active' && !disabled.includes(skill.id))
.flatMap((skill) => sectionsForPage(skill, page)
.filter((section) => !placement || section.placement === placement)
.map((section) => ({ skill, section })));
}, [page, placement, customKey, disabledKey]);
}
/**
* The records a section may be read from.
*
* Loaded once per surface rather than per section, and passed to the resolver —
* which is the only thing that touches them. A section never queries anything.
*
* Exported because the panel needs the identical reading: an answer drawn in the
* chat and a card drawn on the page must resolve from one set of collections, or
* the two surfaces of the same definition can disagree. Every hook inside is a
* cache read the app has already paid for.
*/
export function useSkillDataContext(context) {
/* What the page published about itself, for a surface that was not handed a
record directly. An explicit `context` prop still wins — a card knows which
position it is, and that is more specific than what the page says. */
const published = usePageContext();
const { data: applications = [] } = useApplications();
const { data: positions = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: courses = [] } = useCourses();
const { data: workerProfiles = [] } = useWorkerProfiles();
/* The hires and the audit trail, for the sources that read outcomes and
events. Same caches the pages themselves render from, so a section and the
panel beside it can never disagree. */
const { data: staff = [] } = useStaff();
const { data: activity = [] } = useUserActivity();
/* Who is already committed, for the sources that score people against a role:
the matching engine reads availability from assignments, and without them
everybody would look free. */
const { data: assignments = [] } = useAssignments();
const { data: user } = useCurrentUser();
const { statesFor } = useWorkforcePaths();
const trainingPaths = useMemo(
() => statesFor(courses, profileForEmail(workerProfiles, user?.email)),
[statesFor, courses, workerProfiles, user?.email]
);
return useMemo(() => ({
...published,
...context,
applications,
positions,
interviews,
courses,
workerProfiles,
/* The workforce engine's own name for the same records. Both keys are
published so a resolver can be written against either without a rename
rippling through every existing source. */
profiles: workerProfiles,
assignments,
staff,
activity,
trainingPaths,
/* `published` belongs here: it is what changes when the reader edits the
form this panel is sitting beside, and leaving it out froze every
section on the first value the page ever published. */
}), [published, context, applications, positions, interviews, courses, workerProfiles, assignments,
staff, activity, trainingPaths]);
}
/** One declared section, resolved and drawn. */
function SkillSection({ skill, section, context }) {
const Component = SECTION_COMPONENTS[section.type];
const data = useMemo(
() => resolveSkillData(section, context),
[section, context]
);
/* How this section writes back, if the page is offering that write at all.
Null on every page that is not, which is what keeps a section declared
`editable` honest on a surface that only reads. */
const apply = usePageAction(section.editable ? section.source : null);
/* Validation refuses an unknown type long before this, so a missing component
means the registry and the vocabulary have drifted apart. Render nothing
rather than a broken panel. */
if (!Component) return null;
return (
<section
aria-label={section.title || skill.name}
className="rounded-2xl border border-border bg-surface p-5 shadow-xs"
>
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-heading text-body font-semibold text-ink-1">
{section.title || skill.name}
</h3>
{section.description && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{section.description}</p>
)}
</div>
{/* Attribution, so an admin can tell an extension from a built-in panel
and knows which skill to switch off. */}
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-2 py-0.5 text-[10px] font-semibold text-krow-blue">
<Sparkles className="h-3 w-3" aria-hidden="true" />
{skill.name}
</span>
</div>
<Component data={data} section={section} onApply={apply} />
</section>
);
}
export function SkillSurface({ page, placement, context = null, className }) {
const sections = useSkillSections(page, placement);
const resolved = useSkillDataContext(context);
if (!sections.length) return null;
return (
<div className={cn('space-y-4', className)}>
{sections.map(({ skill, section }) => (
<SkillSection
key={`${skill.id}:${section.id}`}
skill={skill}
section={section}
context={resolved}
/>
))}
</div>
);
}