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

@@ -44,6 +44,7 @@ import AdminSettings from '@/pages/admin/Settings';
import AdminWorkspace from '@/pages/admin/Workspace';
import AdminWorkspaceSkills from '@/pages/admin/WorkspaceSkills';
import AdminSkillEditor from '@/pages/admin/SkillEditor';
import AdminOwliverSkillEditor from '@/pages/admin/OwliverSkillEditor';
import AdminSkillDevelopment from '@/pages/admin/SkillDevelopment';
import AdminLogin from '@/pages/admin/Login';
import AdminRoute from '@/pages/admin/AdminRoute';
@@ -110,7 +111,13 @@ const AuthenticatedApp = () => {
<Route path="settings" element={<AdminSettings />} />
<Route path="workspace" element={<AdminWorkspace />} />
<Route path="workspace/skills" element={<AdminWorkspaceSkills />} />
{/* Two editors, because a UI skill and an Owliver skill configure
different things. Static segments rank above the dynamic `:id`,
so `skills/owliver/...` cannot be read as a skill called
"owliver" — and the two original addresses are untouched. */}
<Route path="workspace/skills/new" element={<AdminSkillEditor />} />
<Route path="workspace/skills/owliver/new" element={<AdminOwliverSkillEditor />} />
<Route path="workspace/skills/owliver/:id" element={<AdminOwliverSkillEditor />} />
<Route path="workspace/skills/:id" element={<AdminSkillEditor />} />
<Route path="workspace/skill-development" element={<AdminSkillDevelopment />} />
</Route>

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>
);
}

315
src/lib/hiringRecords.js Normal file
View File

@@ -0,0 +1,315 @@
/**
* The hiring record, derived once.
*
* Analytics and Hired History ask different questions of the same facts — "how
* is our hiring performing" and "who did we hire, and what happened" — and they
* answer them with different pages. What they must never do is *count*
* differently: a total on one page and the same total on the other have to be
* the same number, or the two pages stop being two views and become two claims.
*
* So the counting lives here, in plain functions over the collections the app
* already holds, and each page renders what it needs from the result. Nothing in
* this module knows what either page looks like.
*/
/** The mean of a numeric list, rounded. Zero for an empty list. */
export const avg = (xs) => {
const values = xs.filter((n) => Number.isFinite(n));
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
};
/**
* Everyone hired, as one list.
*
* A Staff record is the hire; the application it came from carries how long it
* took and what it scored, and the posting carries the department. Joined here
* so neither page repeats the join — and so "department" means the same thing
* on both.
*/
export function buildHires({ staff = [], applications = [], postings = [] }) {
return staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
: null;
return {
...s,
company: s.company || posting?.company || '—',
department: posting?.role_category || s.department || '—',
role: s.role || posting?.title || '—',
timeToHire: days || s.timeToHire || null,
score: s.ai_score || app?.ai_score || s.score || null,
profile_tier: s.profile_tier || 'skilled',
hire_date: s.hire_date || s.created_date || null,
applicationId: app?.id || s.application_id || null,
};
});
}
/**
* Demo fill, carried over from the page this module was extracted from.
*
* The store seeds three Staff records; Hired History has always padded that to
* eight so the page reads as a hiring history rather than as three rows. That
* padding is pre-existing product behaviour, not something derived — it is kept
* here, named for what it is, so both pages show what the page has always shown
* and there is one list to delete when the deployment has real volume.
*
* It only ever *adds* people the store does not already have, matched on email,
* so a real hire is never shadowed by a demo one.
*/
const DEMO_FILL = [
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
];
/** The joined hires, with the demo fill applied for anyone not already on file. */
export function hiresWithFill(sources) {
const live = buildHires(sources);
const seen = new Set(live.map((h) => String(h.email || h.name).toLowerCase()));
const filled = [...live];
for (const person of DEMO_FILL) {
const key = String(person.email || person.name).toLowerCase();
if (!seen.has(key)) {
filled.push({ ...person, company: person.company || '—' });
seen.add(key);
}
}
return filled;
}
/** Headline figures: volume, speed, quality, and how many are still on. */
export function summarise(hires) {
return {
total: hires.length,
speed: avg(hires.map((h) => h.timeToHire)),
quality: avg(hires.map((h) => h.score)),
active: hires.filter((h) => h.status !== 'inactive').length,
onboarding: hires.filter((h) => h.status === 'onboarding').length,
};
}
/** The application stages this product counts, in the order they happen. */
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
/**
* Applied → screened → shortlisted → interview → hired, with the pass-through
* rate and the loss at each step.
*
* Counted at-or-beyond, so a candidate who reached interview is counted as
* having been screened — a funnel that counts only the current status shows
* later stages as larger than earlier ones, which is not a funnel.
*/
export function buildFunnel(applications) {
const atOrBeyond = (stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from).length;
};
const stages = [
{ key: 'applied', label: 'Applied', count: applications.length },
{ key: 'ai_screened', label: 'Screened', count: atOrBeyond('ai_screened') },
{ key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond('shortlisted') },
{ key: 'interview', label: 'Interview', count: atOrBeyond('interview') },
{ key: 'hired', label: 'Hired', count: applications.filter((a) => a.status === 'hired').length },
];
const transitions = stages.slice(1).map((stage, i) => {
const previous = stages[i];
return {
from: previous.key,
to: stage.key,
rate: previous.count ? Math.round((stage.count / previous.count) * 100) : 0,
lost: Math.max(0, previous.count - stage.count),
};
});
/* The step losing the most people — the one worth acting on. */
const weakest = transitions.reduce(
(worst, t) => (!worst || t.rate < worst.rate ? t : worst),
null
);
return {
stages,
transitions,
weakestKey: weakest?.to || null,
conversion: applications.length
? Math.round((stages[4].count / applications.length) * 100)
: 0,
};
}
/** Cumulative hires by month — a trend needs a baseline, not a single bar. */
export function buildTrend(hires) {
const byMonth = new Map();
hires.filter((h) => h.hire_date).forEach((h) => {
const d = new Date(h.hire_date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
byMonth.set(key, (byMonth.get(key) || 0) + 1);
});
let running = 0;
return [...byMonth.entries()].sort().map(([key, count]) => {
running += count;
const [y, m] = key.split('-');
return {
label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
hires: count,
cumulative: running,
};
});
}
/** Hires grouped by department, best-performing first. */
export function byDepartment(hires) {
const map = new Map();
hires.forEach((h) => {
const name = h.department && h.department !== '—' ? h.department : 'General';
const entry = map.get(name) || {
name, department: name, count: 0, scores: [], times: [], rolesSet: new Set(), hiresList: [],
};
entry.count += 1;
if (h.score) entry.scores.push(h.score);
if (h.timeToHire) entry.times.push(h.timeToHire);
if (h.role && h.role !== '—') entry.rolesSet.add(h.role);
entry.hiresList.push(h);
map.set(name, entry);
});
return [...map.values()]
.map((e) => ({
name: e.name,
department: e.department,
count: e.count,
scores: e.scores,
avgScore: avg(e.scores),
avgTimeToHire: avg(e.times),
roles: [...e.rolesSet],
hiresList: e.hiresList,
}))
.sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
}
/** Hires grouped by the role they were hired into, most-filled first. */
export function byPosition(hires) {
const map = new Map();
hires.forEach((h) => {
const key = h.role && h.role !== '—' ? h.role : 'Unspecified';
const entry = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
entry.count += 1;
if (h.score) entry.scores.push(h.score);
if (h.timeToHire) entry.days.push(h.timeToHire);
if (h.client_rating) { entry.rated += 1; entry.ratings.push(h.client_rating); }
map.set(key, entry);
});
return [...map.values()]
.map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
: null,
}))
.sort((a, b) => b.count - a.count);
}
/**
* Where hiring is slow, and where it is fast.
*
* Velocity is only meaningful against something, so each role is measured
* against the workspace's own median rather than an industry figure nobody
* here can check.
*/
export function buildEfficiency(hires) {
const timed = hires.filter((h) => h.timeToHire);
if (!timed.length) return { median: 0, fastest: [], slowest: [], within48h: 0 };
const sorted = [...timed].sort((a, b) => a.timeToHire - b.timeToHire);
const median = sorted[Math.floor(sorted.length / 2)].timeToHire;
const roles = byPosition(timed).filter((r) => r.avgDays);
return {
median,
fastest: [...roles].sort((a, b) => a.avgDays - b.avgDays).slice(0, 4),
slowest: [...roles].sort((a, b) => b.avgDays - a.avgDays).slice(0, 4),
within48h: Math.round((timed.filter((h) => h.timeToHire <= 2).length / timed.length) * 100),
};
}
/**
* What the numbers say, as findings rather than figures.
*
* Every one is conditional on the data supporting it: a claim about the fastest
* department is only made when there is more than one department to compare, and
* a risk is only raised when something is actually at risk. A page that always
* shows three insights is showing decoration.
*/
export function buildInsights({ hires, departments, positions, funnel, efficiency }) {
const out = [];
const summary = summarise(hires);
if (departments.length > 1) {
const best = departments[0];
out.push({
tone: 'success',
title: `${best.department} is hiring the strongest candidates`,
body: `${best.count} hire${best.count === 1 ? '' : 's'} at an average score of ${best.avgScore}, against ${summary.quality} across the workspace.`,
});
}
if (funnel.weakestKey) {
const weak = funnel.transitions.find((t) => t.to === funnel.weakestKey);
const label = funnel.stages.find((s) => s.key === funnel.weakestKey)?.label;
if (weak && weak.lost > 0) {
out.push({
tone: 'warning',
title: `The largest drop-off is into ${label}`,
body: `${weak.rate}% pass through and ${weak.lost} candidate${weak.lost === 1 ? '' : 's'} stop there. It is the step with the most to recover.`,
});
}
}
if (efficiency.slowest.length && efficiency.median) {
const slow = efficiency.slowest[0];
if (slow.avgDays > efficiency.median) {
out.push({
tone: 'risk',
title: `${slow.role} takes longest to fill`,
body: `${slow.avgDays} days on average against a median of ${efficiency.median}. ${slow.count} hire${slow.count === 1 ? '' : 's'} on that record.`,
});
}
}
const unrated = positions.reduce((n, p) => n + (p.count - p.rated), 0);
if (unrated > 0) {
out.push({
tone: 'info',
title: `${unrated} hire${unrated === 1 ? '' : 's'} ${unrated === 1 ? 'has' : 'have'} no client review`,
body: 'Quality of hire is measured on the AI score alone until a review lands. Chasing these closes the loop on outcomes.',
});
}
if (funnel.conversion) {
out.push({
tone: 'info',
title: `${funnel.conversion}% of applicants are hired`,
body: `${funnel.stages[4].count} of ${funnel.stages[0].count} applications reached a hire.`,
});
}
return out;
}

View File

@@ -432,9 +432,12 @@ const HANDLERS = {
* Reached only from the confirmation step, after the position has been read
* back and the user has chosen to create it.
*/
create_position: ({ draft }) => ({
/* `status` is the one the conversation's confirmation step chose — draft or
active — and is passed to the payload builder the form already uses, so
both routes write the same record with the same defaults. */
create_position: ({ draft, status }) => ({
type: 'create_position',
data: toPositionPayload(draft || {}),
data: toPositionPayload(draft || {}, status ? { status } : undefined),
}),
/**

View File

@@ -9,30 +9,132 @@ import { parseSkill } from './registry';
* writers with two shapes would be a second skill system by accident.
*/
/** The starting definition offered to an author, in the existing format. */
export const skillTemplate = ({ id = '', name = '', description = '', pages = [] } = {}) => `---
id: ${id || 'my-skill'}
name: ${name || 'My Skill'}
description: ${description || 'What this skill helps Owliver do.'}
pages:
${(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n')}
status: active
triggers:
- ${(name || 'my skill').toLowerCase()}
---
/**
* The starting definitions offered to an author.
*
* Two templates, because there are two jobs and one of them was being learned
* from the other's example. A UI skill's first draft declares a section; an
* Owliver skill's declares triggers and the shapes of an answer. Both are the
* same format, read by the same parser — what differs is which half of it the
* author is being handed.
*/
# ${name || 'My Skill'}
const frontMatter = ({ id, name, description, pages, fallback }) => [
`id: ${id || fallback.id}`,
`name: ${name || fallback.name}`,
`description: ${description || fallback.description}`,
'pages:',
(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n'),
'status: active',
].join('\n');
/** A definition that draws a section on the pages it names. */
export const uiSkillTemplate = ({
id = '', name = '', description = '', pages = [],
type = 'flow', placement = '', source = 'position.activity', periods = [],
} = {}) => `---
${frontMatter({
id,
name,
description,
pages,
fallback: {
id: 'my-ui-skill',
name: 'My UI Skill',
description: 'What this skill adds to the page.',
},
})}
ui:
type: ${type}
${placement ? ` placement: ${placement}\n` : ''} title: ${name || 'My UI Skill'}
source: ${source}
${periods.length ? ` periods:\n${periods.map((p) => ` - ${p}`).join('\n')}\n` : ''}---
# ${name || 'My UI Skill'}
## Purpose
Describe what Owliver should help with on these pages.
Describe what this section shows, and why it belongs on these pages.
## Capabilities
- Describe one thing the skill can do.
- Describe one thing the section reports.
- Add more as needed.
`;
/** A definition that teaches Owliver what it can be asked for. */
export const owliverSkillTemplate = ({
id = '', name = '', description = '', pages = [],
triggers = [], suggestions = [], capabilities = [], source = '', periods = [],
} = {}) => {
const label = name || 'My Owliver Skill';
const lines = [`---
${frontMatter({
id,
name,
description,
pages,
fallback: {
id: 'my-owliver-skill',
name: label,
description: 'What this skill helps Owliver answer.',
},
})}`];
lines.push('triggers:');
lines.push((triggers.length ? triggers : [label.toLowerCase()]).map((t) => ` - ${t}`).join('\n'));
lines.push('owliver:');
lines.push(' enabled: true');
if (suggestions.length) {
lines.push(' suggestions:');
lines.push(suggestions.map((s) => ` - ${s}`).join('\n'));
}
if (capabilities.length) {
lines.push(' capabilities:');
lines.push(capabilities.map((c) => ` - ${c}`).join('\n'));
if (source) {
lines.push(' responses:');
for (const capability of capabilities) {
lines.push(` ${capability}:`);
lines.push(` source: ${source}`);
if (periods.length) {
lines.push(' periods:');
lines.push(periods.map((p) => ` - ${p}`).join('\n'));
}
}
}
}
lines.push(`---
# ${label}
## Purpose
Describe what Owliver should be able to answer on these pages.
## Capabilities
- Describe one thing Owliver can be asked for.
- Add more as needed.
`);
return lines.join('\n');
};
/**
* The template the Add Skill dialog offers.
*
* That dialog opens from Owliver's own header, mid-conversation, so what it
* hands the author is an Owliver skill. Kept under its original name because
* it is what the dialog already imports.
*/
export const skillTemplate = owliverSkillTemplate;
/** Parses a stored entry, tolerating the bare-string form. */
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');

View File

@@ -0,0 +1,516 @@
import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
import { CRITERIA_LABELS } from '@/lib/positionModel';
import { poolFor } from '@/lib/workforce';
import { candidateRoute } from './workforceFlow';
/**
* The one place a skill's declared data source becomes real data.
*
* A section says `data.source: position.activity`. It does not say where that
* comes from, cannot reach a store, and cannot name a field. This module owns
* the mapping — source id in, normalized reading out — which is what keeps a
* declarative file from turning into a query language.
*
* Two rules hold throughout:
*
* - **Real records only.** Every figure is counted from the collections the
* application already holds. Nothing is generated to make a section look
* populated; a source with nothing to report returns `empty: true` and the
* renderer says so.
* - **Time is computed, never stored.** "Today" is a window over the record
* timestamps, evaluated against the current date at read time. No date is
* written into a definition and none is hard-coded here.
*/
const DAY = 24 * 60 * 60 * 1000;
/** Midnight at the start of the given day, in local time. */
const startOfDay = (date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
};
/**
* The window a period covers, as `[from, to)`.
*
* Weeks run Monday to Monday and months from the 1st, which is how the rest of
* the product reports them.
*/
export function periodRange(period, now = new Date()) {
const today = startOfDay(now);
switch (period) {
case 'today':
return { from: today, to: new Date(today.getTime() + DAY) };
case 'yesterday':
return { from: new Date(today.getTime() - DAY), to: today };
case 'last-7-days':
return { from: new Date(today.getTime() - 7 * DAY), to: new Date(today.getTime() + DAY) };
case 'last-week': {
/* The calendar week before the one we are in. */
const weekday = (today.getDay() + 6) % 7;
const thisMonday = new Date(today.getTime() - weekday * DAY);
return { from: new Date(thisMonday.getTime() - 7 * DAY), to: thisMonday };
}
case 'this-month': {
const from = new Date(today.getFullYear(), today.getMonth(), 1);
return { from, to: new Date(today.getFullYear(), today.getMonth() + 1, 1) };
}
case 'previous-month': {
const from = new Date(today.getFullYear(), today.getMonth() - 1, 1);
return { from, to: new Date(today.getFullYear(), today.getMonth(), 1) };
}
default:
return null;
}
}
/** Records whose `created_date` falls inside the window. */
const inPeriod = (records, period, now) => {
const range = periodRange(period, now);
if (!range) return [];
return records.filter((record) => {
const at = new Date(record.created_date || record.updated_date || 0).getTime();
return at >= range.from.getTime() && at < range.to.getTime();
});
};
/**
* Why a matched candidate fits, in one line.
*
* Met requirements first, then the gaps, then what is known about availability —
* the same order and the same words the panel's match cards use, because they
* are read from the same row. A gap is stated as a gap: a line that only listed
* strengths would make every candidate look like a strong match.
*/
function matchDetail(row) {
if (!row.match) return 'Nothing on this position to score this candidate against';
const parts = [
...row.match.met.map((line) => `✓ ${line.name} — ${line.heldLabel}`),
...row.match.gaps.map((line) => `⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`),
];
if (row.availability?.known === false) parts.push('Availability not on file');
else if (row.availability) {
parts.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
}
return parts.join(' · ');
}
/** The application stages this product counts, in the order they happen. */
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
const atOrBeyond = (applications, stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
};
/** Applications counted by period — the reading behind an activity section. */
function activityOverTime(applications, periods, now) {
const wanted = periods.length ? periods : ['today', 'yesterday', 'last-week'];
const steps = wanted
.filter((period) => SUPPORTED_PERIODS.includes(period))
.map((period) => {
const records = inPeriod(applications, period, now);
return {
id: period,
label: periodLabel(period),
value: records.length,
detail: records.length
? `${records.length} application${records.length === 1 ? '' : 's'}`
: 'No applications',
records,
};
});
return {
steps,
total: applications.length,
empty: steps.every((s) => s.value === 0),
emptyNote: applications.length
? 'No applications in these periods.'
: 'No applications on this position yet.',
};
}
/** The hiring funnel for a set of applications. */
function pipelineOf(applications) {
const steps = [
{ id: 'applied', label: 'Applied', value: applications.length },
{ id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length },
{ id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').length },
{ id: 'interview', label: 'Interview', value: atOrBeyond(applications, 'interview').length },
{ id: 'hired', label: 'Hired', value: applications.filter((a) => a.status === 'hired').length },
];
return {
steps,
total: applications.length,
empty: applications.length === 0,
emptyNote: 'No applications on this position yet.',
};
}
/**
* Every source the vocabulary offers, and how each is read.
*
* Keyed by the same ids `surfaces.js` publishes, so the list an author can
* choose from and the list that can actually be resolved are the same list.
*/
const RESOLVERS = {
'position.activity': ({ position, applications }, section, now) => {
const mine = applications.filter((a) => a.job_posting_id === position?.id);
return activityOverTime(mine, section.periods, now);
},
'position.pipeline': ({ position, applications }) =>
pipelineOf(applications.filter((a) => a.job_posting_id === position?.id)),
'position.candidates': ({ position, applications }, section) => {
const mine = applications
.filter((a) => a.job_posting_id === position?.id)
.sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0))
.slice(0, section.limit || 5);
return {
items: mine.map((a) => ({
id: a.id,
title: a.applicant_name,
detail: [
a.years_experience != null ? `${a.years_experience} yrs experience` : null,
String(a.status || '').replace(/_/g, ' '),
].filter(Boolean).join(' · '),
value: a.ai_score > 0 ? a.ai_score : null,
to: `/admin/candidates/${a.id}`,
})),
columns: [
{ key: 'title', label: 'Candidate' },
{ key: 'detail', label: 'Status' },
{ key: 'value', label: 'Score', align: 'right' },
],
empty: mine.length === 0,
emptyNote: 'No candidates have applied to this position yet.',
};
},
/**
* The candidate pool, scored against this position.
*
* Every figure here comes from `poolFor` — the same engine behind the
* workforce conversation and the position page's own recommendations. This
* resolver ranks nothing and scores nothing; it reads the rows the engine
* returned and states them, including *why* each one fits, in the engine's own
* terms. A candidate the position gives nothing to measure against is reported
* as unscored rather than given a number.
*
* `to` is the candidate's real record, carrying the position they were being
* considered for — the same route the panel's match cards open.
*/
'position.matches': ({ position, ...context }, section) => {
if (!position) {
return { items: [], empty: true, emptyNote: 'No position to match candidates against.' };
}
const rows = poolFor(position, {
profiles: context.profiles || context.workerProfiles || [],
applications: context.applications || [],
assignments: context.assignments || [],
courses: context.courses || [],
staff: context.staff || [],
}).slice(0, section.limit || 5);
return {
items: rows.map((row) => ({
id: row.candidateId,
title: row.name,
/* The met requirements and the gaps, as the engine stated them. */
detail: matchDetail(row),
value: row.scored ? row.score : null,
to: candidateRoute(row, context.applications || [], position),
})),
columns: [
{ key: 'title', label: 'Candidate' },
{ key: 'detail', label: 'Why' },
{ key: 'value', label: 'Match', align: 'right' },
],
empty: rows.length === 0,
emptyNote: 'No candidates on file to score against this position yet.',
};
},
'position.requirements': ({ position }) => {
const items = [
position?.min_experience_years
? { id: 'experience', title: 'Minimum experience', detail: `${position.min_experience_years} years` }
: null,
position?.english_required
? { id: 'english', title: 'English level', detail: String(position.english_required) }
: null,
...(position?.certifications_required || []).map((c) => ({
id: `cert-${c}`, title: 'Certification', detail: c,
})),
...(position?.skill_requirements || []).map((r) => ({
id: `skill-${r.skill_id}`, title: r.skill_id, detail: `${r.level} · weight ${r.weight}`,
})),
].filter(Boolean);
return {
items,
columns: [{ key: 'title', label: 'Requirement' }, { key: 'detail', label: 'Needs' }],
empty: items.length === 0,
emptyNote: 'This position states no requirements.',
};
},
'candidate.readiness': ({ candidate }) => {
const breakdown = candidate?.score_breakdown || {};
const items = Object.entries(breakdown)
.filter(([, value]) => Number(value) > 0)
.map(([key, value]) => ({
id: key,
title: key.replace(/_/g, ' '),
value: Number(value),
max: 100,
}));
return {
items,
columns: [{ key: 'title', label: 'Dimension' }, { key: 'value', label: 'Score', align: 'right' }],
empty: items.length === 0,
emptyNote: 'This candidate has not been screened, so there are no dimensions to show.',
};
},
'candidate.activity': ({ candidate, interviews = [] }) => {
const events = [
candidate?.created_date && {
id: 'applied', title: 'Applied', detail: candidate.job_title, at: candidate.created_date,
},
candidate?.ai_score > 0 && {
id: 'screened', title: 'AI screened', detail: `Scored ${candidate.ai_score}`, at: candidate.updated_date,
},
...interviews
.filter((i) => i.application_id === candidate?.id)
.map((i) => ({ id: i.id, title: 'Interview', detail: i.status, at: i.created_date })),
candidate?.status === 'hired' && {
id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date,
},
].filter(Boolean);
return {
items: events,
empty: events.length === 0,
emptyNote: 'Nothing has happened on this record yet.',
};
},
'candidates.pipeline': ({ applications }) => pipelineOf(applications),
'candidates.activity': ({ applications }, section, now) =>
activityOverTime(applications, section.periods, now),
'positions.demand': ({ positions = [], applications }, section) => {
const items = positions
.filter((p) => p.status === 'active')
.slice(0, section.limit || 5)
.map((p) => {
const mine = applications.filter((a) => a.job_posting_id === p.id);
return {
id: p.id,
title: p.title,
detail: [p.company, p.location].filter(Boolean).join(' · '),
value: mine.length,
to: `/admin/positions/${p.id}`,
};
});
return {
items,
columns: [
{ key: 'title', label: 'Position' },
{ key: 'detail', label: 'Client' },
{ key: 'value', label: 'Applicants', align: 'right' },
],
empty: items.length === 0,
emptyNote: 'No open positions.',
};
},
'workforce.training': ({ trainingPaths = [] }, section) => {
const items = trainingPaths.slice(0, section.limit || 10).map(({ definition, state }) => ({
id: definition.id,
title: state.name,
detail: state.verifiedLabel,
value: state.totalModules ? Math.round((state.totalCompleted / state.totalModules) * 100) : 0,
max: 100,
}));
return {
items,
columns: [
{ key: 'title', label: 'Path' },
{ key: 'detail', label: 'Level' },
{ key: 'value', label: 'Complete', align: 'right' },
],
empty: items.length === 0,
emptyNote: 'No training paths are registered.',
};
},
/**
* The vetting weights on the position in context.
*
* The same reading whether that position is a saved record or the draft being
* typed into Create Position — both carry `vetting_criteria`, so a section
* declared once reports the specification as it stands on either surface.
*/
'position.vetting': ({ position }) => {
const criteria = position?.vetting_criteria || {};
const steps = Object.entries(criteria).map(([key, value]) => ({
id: key,
label: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
title: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
value: Number(value) || 0,
max: 100,
detail: `${Number(value) || 0}% of the screening score`,
}));
const total = steps.reduce((sum, s) => sum + s.value, 0);
return {
steps,
items: steps,
total,
columns: [
{ key: 'title', label: 'Criterion' },
{ key: 'value', label: 'Weight', align: 'right' },
],
empty: steps.length === 0,
emptyNote: 'This position states no vetting weights.',
};
},
/** Everyone hired, most recent first — the record rather than the analysis. */
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
const items = [...staff]
.sort((a, b) => new Date(b.hire_date || b.created_date || 0) - new Date(a.hire_date || a.created_date || 0))
.slice(0, section.limit || 10)
.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = positions.find((p) => p.id === s.job_posting_id);
return {
id: s.id,
title: s.name,
detail: [s.role || posting?.title, posting?.role_category || s.department]
.filter(Boolean).join(' · '),
value: s.ai_score || app?.ai_score || s.score || null,
at: s.hire_date || s.created_date || null,
};
});
return {
items,
columns: [
{ key: 'title', label: 'Hire' },
{ key: 'detail', label: 'Role' },
{ key: 'value', label: 'Score', align: 'right' },
],
empty: items.length === 0,
emptyNote: 'Nobody has been hired yet.',
};
},
/**
* The four figures that answer "how is our hiring performing" — counted from
* applications and the staff records they became, never stored.
*/
'hires.performance': ({ applications = [], staff = [] }) => {
const hired = applications.filter((a) => a.status === 'hired');
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
const days = hired
.map((a) => Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000))
.filter((n) => Number.isFinite(n) && n >= 0);
const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
const total = staff.length || hired.length;
const steps = [
{ id: 'hires', label: 'Total hires', title: 'Total hires', value: total },
{ id: 'speed', label: 'Avg days to hire', title: 'Avg days to hire', value: mean(days) },
{ id: 'quality', label: 'Quality of hire', title: 'Quality of hire', value: mean(scores) },
{
id: 'conversion',
label: 'Conversion rate',
title: 'Conversion rate',
value: applications.length ? Math.round((hired.length / applications.length) * 100) : 0,
},
];
return {
steps,
items: steps,
total,
columns: [
{ key: 'title', label: 'Measure' },
{ key: 'value', label: 'Value', align: 'right' },
],
empty: applications.length === 0 && total === 0,
emptyNote: 'No hiring activity has been recorded yet.',
};
},
/** The workspace audit trail, most recent first. */
'activity.events': ({ activity = [] }, section) => {
const items = [...activity]
.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0))
.slice(0, section.limit || 10)
.map((event) => ({
id: event.id,
title: String(event.event_type || 'event').replace(/_/g, ' '),
detail: [event.user_name, event.details].filter(Boolean).join(' — '),
at: event.created_date,
}));
return {
items,
columns: [
{ key: 'title', label: 'Event' },
{ key: 'detail', label: 'Who' },
],
empty: items.length === 0,
emptyNote: 'No activity has been recorded yet.',
};
},
};
/**
* One section's data, read from the application's own records.
*
* `context` is what the page supplies — the position or candidate being looked
* at, plus the collections it already loaded. A source whose required context
* is missing returns `unavailable`, which the renderer states rather than
* filling in.
*/
export function resolveSkillData(section, context = {}, now = new Date()) {
const resolve = RESOLVERS[section?.source];
if (!resolve) return { unavailable: true, emptyNote: `No resolver for ${section?.source}.` };
if (section.context === 'positionId' && !context.position) {
return { unavailable: true, emptyNote: 'This section needs a position to read.' };
}
if (section.context === 'candidateId' && !context.candidate) {
return { unavailable: true, emptyNote: 'This section needs a candidate to read.' };
}
try {
return resolve(context, section, now);
} catch {
/* A resolver that throws is a bug in this file, not in the definition —
the section reports it has nothing rather than taking the page down. */
return { unavailable: true, emptyNote: 'This section could not be read.' };
}
}

View File

@@ -0,0 +1,247 @@
import { normalizeSection } from './uiConfig';
import {
SUPPORTED_OWLIVER_CAPABILITIES, SUPPORTED_SECTION_TYPES, owliverCapabilityFor,
} from './surfaces';
/**
* The `owliver:` block of a skill definition, checked and normalized.
*
* The same file that extends a page can extend the panel beside it. `ui:` says
* what the page renders; `owliver:` says what can be *asked for*, and both name
* the same data source — so the card and the answer are two readings of one
* declaration rather than two definitions that have to be kept in step.
*
* owliver:
* enabled: true
* suggestions:
* - Show hiring activity
* - Summarize hiring activity
* capabilities:
* - summary
* - flow
* responses:
* flow:
* title: Hiring Activity Flow
* source: position.activity
* steps: [today, yesterday, last-week]
*
* Three properties hold, and each one is a rule the rest of the system relies
* on:
*
* - **A response is a section.** It is normalized by the same function the
* page's `ui:` sections go through, so a capability resolves to the same
* record, the same data source and the same renderer. There is no second
* shape for "the chat version".
* - **The block is optional.** A definition with no `owliver:` normalizes to
* a disabled record and behaves exactly as it did before this existed.
* - **Nothing unknown survives.** Capabilities, sources, periods and shapes
* are checked against the closed vocabulary in `surfaces.js`; an
* unrecognised value is a named error rather than a dropped key.
*/
/** What a definition with no `owliver:` block gets. */
export const NO_OWLIVER = Object.freeze({
enabled: false,
suggestions: [],
capabilities: [],
responses: {},
});
/** The section type a capability is drawn with — `summary` draws nothing. */
const shapeOf = (capability) => owliverCapabilityFor(capability)?.shape || null;
/**
* The reading a response inherits when it does not name one.
*
* A skill that already declares a `ui:` section has stated its source once;
* making it state it again for the panel would be the format asking the author
* to repeat themselves, and would let the two drift apart. The first declared
* section wins, in declaration order.
*/
function inheritedSection(ui) {
for (const page of Object.values(ui || {})) {
const section = (page.sections || [])[0];
if (section) return section;
}
return null;
}
/** One suggestion, in either the plain-string or the mapping form. */
function normalizeSuggestion(raw, { errors, capabilities, index }) {
const where = `owliver.suggestions[${index}]`;
if (typeof raw === 'string' || typeof raw === 'number') {
const label = String(raw).trim();
if (!label) {
errors.push(`${where}: a suggestion needs text.`);
return null;
}
return { label, prompt: label, capability: null };
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
errors.push(`${where}: a suggestion must be a line of text, or a mapping of options.`);
return null;
}
const label = String(raw.label ?? raw.prompt ?? '').trim();
if (!label) {
errors.push(`${where}: a suggestion needs a \`label\`.`);
return null;
}
/* A suggestion may say which capability it asks for. That is what makes a
chip exact — the words are the author's, and the answer is not left to be
re-derived from them. */
const capability = raw.capability == null ? null : String(raw.capability).trim();
if (capability && !SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
errors.push(
`Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
);
return null;
}
if (capability && capabilities.length && !capabilities.includes(capability)) {
errors.push(`${where}: \`${capability}\` is not listed under \`owliver.capabilities\`.`);
return null;
}
return {
label,
prompt: String(raw.prompt ?? raw.label).trim(),
capability: capability || null,
};
}
/**
* The whole `owliver:` block, normalized.
*
* Returns `{ owliver, errors }`. As with `ui:`, what validates is kept and what
* does not is reported: a definition with one bad response still registers its
* good ones, and the author is told why the other was refused.
*/
export function normalizeSkillOwliver(raw, { ui = {}, skillId = '', skillName = '' } = {}) {
const errors = [];
if (raw == null) return { owliver: NO_OWLIVER, errors };
if (typeof raw !== 'object' || Array.isArray(raw)) {
return { owliver: NO_OWLIVER, errors: ['`owliver` must be a mapping of options.'] };
}
/* Declaring the block is the opt-in; `enabled: false` is how it is switched
off without deleting what was written. */
const enabled = raw.enabled !== false;
/* Capabilities may be listed, or left to be read off the responses — which is
what a definition that writes one response and nothing else means. */
const declared = Array.isArray(raw.capabilities)
? raw.capabilities.map((c) => String(c).trim()).filter(Boolean)
: [];
const responsesRaw = raw.responses && typeof raw.responses === 'object' && !Array.isArray(raw.responses)
? raw.responses
: {};
if (raw.responses != null && !Object.keys(responsesRaw).length) {
errors.push('`owliver.responses` must be a mapping of capability names to responses.');
}
const capabilities = [];
for (const capability of [...declared, ...Object.keys(responsesRaw)]) {
if (!SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
errors.push(
`Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
);
continue;
}
if (!capabilities.includes(capability)) capabilities.push(capability);
}
/* Suggestions are read after capabilities, so one naming a capability can be
checked against what the skill actually offers. */
const suggestionsRaw = raw.suggestions == null ? [] : raw.suggestions;
let suggestions = [];
if (!Array.isArray(suggestionsRaw)) {
errors.push('`owliver.suggestions` must be a list.');
} else {
suggestions = suggestionsRaw
.map((entry, index) => normalizeSuggestion(entry, { errors, capabilities, index }))
.filter(Boolean);
}
/* Every capability resolves to a section — declared, or inherited from the
page UI this skill already configures. A capability that can name no
reading is refused: it would otherwise register as something Owliver
offers and then have nothing to answer with. */
const inherited = inheritedSection(ui);
const responses = {};
const seen = new Set();
for (const capability of capabilities) {
const declaredResponse = responsesRaw[capability];
if (declaredResponse != null
&& (typeof declaredResponse !== 'object' || Array.isArray(declaredResponse))) {
errors.push(`owliver.responses.${capability}: expected a mapping of options.`);
continue;
}
const response = declaredResponse || {};
/* `steps:` is what a flow reads like in a definition; `periods:` is what
the rest of the format calls the same list. */
const periods = response.steps ?? response.periods ?? (declaredResponse ? null : inherited?.periods);
const source = response.data?.source ?? response.source ?? inherited?.source;
if (!source) {
errors.push(
`owliver.responses.${capability}: a response needs a \`source\`, or a \`ui:\` section to read from.`
);
continue;
}
const section = normalizeSection(
{
id: `${skillId || 'skill'}-${capability}`,
type: capability,
title: response.title ?? null,
description: response.description ?? null,
source,
periods: periods ?? [],
limit: response.limit ?? inherited?.limit ?? null,
/* Editing is a property of the capability, and is inherited from the
page section the same way the source is: a definition that made its
card adjustable meant the answer to be adjustable too, unless it
says otherwise. */
editable: response.editable ?? (declaredResponse ? false : inherited?.editable) ?? false,
},
{
errors,
seen,
where: `owliver.responses.${capability}`,
fallbackId: skillId,
placement: false,
types: [...SUPPORTED_SECTION_TYPES, 'summary'],
shapeFor: shapeOf,
}
);
if (!section) continue;
responses[capability] = {
...section,
capability,
/* The shape the answer is drawn with, resolved once here so no consumer
has to know that `summary` is the one capability with no component. */
shape: shapeOf(capability),
title: section.title || skillName || null,
};
}
return {
owliver: {
enabled,
suggestions,
/* Only capabilities that resolved to a reading are offered. */
capabilities: capabilities.filter((c) => responses[c]),
responses,
},
errors,
};
}

View File

@@ -0,0 +1,416 @@
import { OWLIVER_CAPABILITIES, dataSourceLabel, owliverCapabilityFor } from './surfaces';
import { skillsForContext } from './registry';
import { resolveSkillData } from './dataResolver';
import { resolvePosition } from './workforceFlow';
/**
* The Owliver half of a skill definition, resolved.
*
* The page reads a definition through `SkillSurface`; this is the other reader.
* Given the page you are on and what you asked, it answers three questions and
* nothing else:
*
* 1. which registered skills apply here,
* 2. which of them you are asking for, and which capability,
* 3. what the answer is, read from the source the definition names.
*
* The rule that makes this an architecture rather than a lookup table: **no
* skill is named here.** There is no `if (skill.id === …)`, no prompt string
* matched against a constant, and no component per skill. A definition is
* matched by what it declares — its triggers, its suggestions, its name, its
* description — and answered by the shape it declares. A skill written after
* this file was last edited resolves exactly as well as one written before it.
*/
/* ── Which skills apply ─────────────────────────────────────────────────── */
/**
* The Owliver-enabled skills registered for a page context.
*
* `skillsForContext` is the single answer to "what is attached here", already
* honouring `pages:`, `status:` and the account's switched-off list — so the
* page's sections and the panel's answers are filtered by one rule, and
* switching a skill off in Settings removes both at once.
*/
export function owliverSkillsForContext(contextId, disabled = [], customSources = []) {
return skillsForContext(contextId, disabled, customSources)
.filter((skill) => skill.owliver?.enabled && skill.owliver.capabilities.length > 0);
}
/* ── Suggestions ────────────────────────────────────────────────────────── */
/** How many chips one skill may contribute, and how many all of them may. */
const PER_SKILL = 3;
const TOTAL = 4;
/**
* The chips a page's skills offer.
*
* Capped deliberately. A workspace with six skills attached would otherwise
* bury the page's own suggestions under twenty of them, and a suggestion nobody
* can find is not a suggestion. A skill contributes its first few, and the set
* as a whole stays within what the composer can show without becoming a menu.
*
* A suggestion naming a capability the skill does not offer is dropped rather
* than shown and then refused.
*/
export function owliverSuggestions(contextId, disabled = [], customSources = [], context = {}) {
const chips = [];
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
/**
* Can this skill answer without asking a question back?
*
* A capability reading one position cannot say anything until it knows
* which — so on a page with nothing selected, clicking it produces a
* question rather than an answer. Those suggestions are still offered, but
* they are marked so the panel can rank them behind the ones that will
* actually answer. That is a property of the declared source, not of any
* particular skill: a definition added tomorrow reading one record is
* ranked the same way.
*/
const needs = (capability) => skill.owliver.responses[capability]?.context || null;
const met = (need) => !need
|| (need === 'positionId' && Boolean(context.position))
|| (need === 'candidateId' && Boolean(context.candidate));
const offered = skill.owliver.suggestions
.filter((s) => !s.capability || skill.owliver.capabilities.includes(s.capability))
.slice(0, PER_SKILL)
/* Deliberately not carried as `capability`: a chip with that field is one
of the *page's* own answers and bypasses routing entirely. A skill's
chip is an ordinary question, and is resolved the same way the same
words typed by hand would be — one path, so a chip can never answer
something the typed form would not. */
.map((s) => {
const need = needs(s.capability || skill.owliver.capabilities[0]);
return {
label: s.label,
prompt: s.prompt,
skillId: skill.id,
skillCapability: s.capability || null,
/* True when clicking this would have to ask which record first. */
deferred: !met(need),
};
});
chips.push(...offered);
}
/* Answerable suggestions first, then the ones that would ask a question
back — stable within each group, so a definition's own order is kept. */
const ready = chips.filter((c) => !c.deferred);
const asking = chips.filter((c) => c.deferred);
return [...ready, ...asking].slice(0, TOTAL);
}
/* ── Matching ───────────────────────────────────────────────────────────── */
const lower = (value) => String(value ?? '').toLowerCase();
/** Words worth matching on — the ones that carry the subject of a question. */
const STOP_WORDS = new Set([
'the', 'a', 'an', 'this', 'that', 'these', 'those', 'my', 'our', 'is', 'are', 'was', 'were',
'show', 'me', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or', 'with', 'what', 'how', 'can',
'you', 'i', 'it', 'please', 'give', 'tell', 'about', 'here', 'now', 'current', 'currently',
]);
const words = (value) => lower(value).split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
/**
* Does a declared trigger match? `*` stands for anything in between, the same
* way the registry's own trigger matching reads it.
*/
function triggerMatches(trigger, question) {
if (!trigger.includes('*')) return question.includes(trigger);
const pattern = trigger
.split('*')
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[\\s\\S]{0,40}?');
return new RegExp(pattern).test(question);
}
/**
* How strongly a question asks for this skill.
*
* Evidence is weighted by how deliberate it is. A suggestion the author wrote
* and the reader clicked is the strongest signal there is; a declared trigger
* is next; the skill's own name is next; and shared words with its description
* are the weakest — enough to break a tie, never enough to win on their own.
*/
function scoreSkill(skill, question) {
const q = lower(question);
const suggestion = skill.owliver.suggestions.find((s) => lower(s.prompt) === q || lower(s.label) === q);
if (suggestion) return { score: 100, suggestion };
/**
* Deliberate evidence: the author said this skill answers this.
*
* A suggestion the reader is echoing, a declared trigger, or the skill's own
* name. One of these must hold before a definition may claim a question at
* all — see the floor below.
*/
let deliberate = 0;
if (skill.owliver.suggestions.some((s) => q.includes(lower(s.label)))) deliberate += 40;
if (skill.triggers.some((t) => triggerMatches(t, q))) deliberate += 30;
if (skill.name && q.includes(lower(skill.name))) deliberate += 20;
/**
* The floor. Sharing a word with a description is not a claim.
*
* This is the bug that let "create a position" be answered by a candidate
* matching skill: its description happened to contain the word "position",
* which scored three points, and three points beat nothing. Corroboration
* was being treated as evidence.
*
* So description overlap can now only *break a tie* between definitions that
* already named the subject, and can never qualify one on its own. A skill
* about candidates cannot claim a question about creating a role however its
* description happens to be worded — which is the general property, not a
* fix aimed at these two definitions.
*/
if (!deliberate) return { score: 0, suggestion: null };
const overlap = words(skill.description).filter((w) => q.includes(w)).length;
return { score: deliberate + Math.min(overlap * 3, 9), suggestion: null };
}
/** The capability a question asks for, from the shape words it uses. */
function scoreCapability(capability, question) {
const q = lower(question);
const definition = owliverCapabilityFor(capability);
if (!definition) return 0;
/* Longest matching term wins, so "as a flow" beats "flow" and a question
naming two shapes resolves to the more explicit one. */
return definition.terms.reduce(
(best, term) => (q.includes(term) && term.length > best ? term.length : best),
0
);
}
/**
* The skill and capability a question resolves to, or null.
*
* Both halves have to hold: a question that names no registered skill is not
* this system's to answer, and a skill matched with no capability falls back to
* the first one its definition declares — which is what makes "Show hiring
* activity" work without the author writing a trigger per shape.
*/
export function matchOwliverSkill(question, skills = []) {
let best = null;
for (const skill of skills) {
const { score, suggestion } = scoreSkill(skill, question);
if (score <= 0) continue;
if (!best || score > best.score) best = { skill, score, suggestion };
}
if (!best) return null;
const { skill, suggestion, score } = best;
const available = skill.owliver.capabilities;
/**
* `exact` means the question *is* a suggestion this definition published —
* the reader clicked a chip, or typed its words. It is the strongest claim
* anything can have on a question, and callers weighing this match against
* another matcher need to be able to see that rather than infer it from a
* number.
*/
const exact = Boolean(suggestion);
/* A chip that declared its capability has already answered this. */
if (suggestion?.capability && available.includes(suggestion.capability)) {
return { skill, capability: suggestion.capability, score, exact };
}
const asked = available
.map((capability) => ({ capability, weight: scoreCapability(capability, question) }))
.filter((c) => c.weight > 0)
.sort((a, b) => b.weight - a.weight)[0];
return { skill, capability: asked?.capability || available[0], score, exact };
}
/* ── The record a response is about ─────────────────────────────────────── */
/**
* The entity a source needs, resolved from the question and the page.
*
* Sources declare what they need — a position, a candidate, a draft, or
* nothing — and this is the one place that need is met. Three orders of
* evidence, most specific first:
*
* 1. the question named a record ("summarize hiring activity for Line Cook"),
* 2. the page has one open (the drawer, the form being filled in),
* 3. neither, and the answer has to ask.
*
* A source needing nothing resolves against the workspace and is always met.
*
* Naming the record wins over the page's selection deliberately: an admin who
* says which role they mean has said so, and answering about a different one
* because a drawer happened to be open would be worse than asking.
*/
export function resolveEntity(section, question, context = {}) {
const need = section.context;
if (!need) return { context, ok: true };
if (need === 'positionId') {
const named = resolvePosition(question, context.positions || [], null);
const position = named || context.position || null;
return position
? { context: { ...context, position }, ok: true }
: { ok: false, need: 'position' };
}
if (need === 'candidateId') {
const q = lower(question);
const named = (context.applications || []).find(
(a) => a.applicant_name && q.includes(lower(a.applicant_name))
);
const candidate = named || context.candidate || null;
return candidate
? { context: { ...context, candidate }, ok: true }
: { ok: false, need: 'candidate' };
}
return { context, ok: true };
}
/* ── The answer ─────────────────────────────────────────────────────────── */
/** The rows a reading offers, whatever shape its source returns them in. */
const rowsOf = (data) => data?.steps || data?.items || [];
/**
* One row, read back as a line.
*
* A reading carries a figure, a description of it, or both, depending on the
* source — so the line is assembled from what is there rather than from a fixed
* template, and a row with a description that already states its figure
* ("2 applications") does not repeat it.
*/
function rowLine(row) {
const label = String(row.label || row.title || row.id || '').trim();
if (!label) return null;
const value = row.value == null || row.value === '' ? null : String(row.value);
const detail = row.detail ? String(row.detail) : null;
const tail = detail
? (value && !detail.includes(value) ? `${detail} · ${value}` : detail)
: value;
return tail ? `${label} — ${tail}` : label;
}
/**
* A summary, built from whatever the source returned.
*
* Generic on purpose: it reads rows and states them. Nothing here knows what a
* period is, what a stage is, or which skill asked — which is precisely why a
* definition written tomorrow gets a summary without this function changing.
*/
export function summaryLines(data) {
return rowsOf(data).map((row) => rowLine(row)).filter(Boolean);
}
/** The label a reading is introduced by — the definition's words, then the source's. */
export const responseTitle = (skill, section) =>
section.title || `${skill.name} — ${dataSourceLabel(section.source)}`;
/**
* Everything an answer needs, resolved: the section, the data, and whether the
* page could supply the record the source required.
*
* Returns `{ skill, capability, section, data, missing }`. `missing` names what
* the caller must ask for; when it is null the reading is real and complete.
*/
export function resolveOwliverResponse({ skill, capability, question, context = {}, now = new Date() }) {
const section = skill.owliver.responses[capability];
if (!section) return null;
const entity = resolveEntity(section, question, context);
if (!entity.ok) {
return { skill, capability, section, data: null, missing: entity.need };
}
/* The same resolver the page's own sections go through, on the same
collections — so the panel and the card beside it cannot report different
figures for the same position. */
const data = resolveSkillData(section, entity.context, now);
return { skill, capability, section, data, missing: null, context: entity.context };
}
/**
* A reading, reduced to what a drawn section reads.
*
* A reply is kept in the thread, so it is stored: the records a source counted
* are the evidence behind a figure, not part of the answer, and writing every
* application into session storage to draw one bar would be paying for the
* whole dataset per turn. Nothing the renderers use is dropped.
*/
export function presentable(data) {
if (!data) return data;
const strip = ({ records, ...row }) => row;
return {
...data,
...(data.steps ? { steps: data.steps.map(strip) } : null),
...(data.items ? { items: data.items.map(strip) } : null),
};
}
/** Every capability the product understands, for the editor and the previews. */
export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map(
({ id, label, summary }) => ({ id, label, summary })
);
/* ── Suggestions for a record that has just appeared ────────────────────── */
/**
* What can now be asked about a record the conversation just produced.
*
* Creating a position is the moment "who could do this?" becomes worth asking,
* and the panel is the only thing that knows a position now exists. Rather than
* naming a skill to offer — which would put a candidate-matching feature inside
* the position-creation flow — this asks the registry the general question: of
* the skills attached to this page, which declare a capability whose reading is
* *about one position*? Those are exactly the ones that can say something about
* the record just made.
*
* The record's own title is appended to each prompt, so the answer resolves
* against it directly and the reader is never asked to pick from a list that
* includes the position they are looking at. Nothing is named here: a skill
* added tomorrow that reads a position is offered on the same terms.
*/
export function suggestionsForPosition(contextId, disabled = [], customSources = [], position) {
if (!position?.title) return [];
const chips = [];
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
/* Only capabilities that read one position — a workspace-wide reading has
nothing to do with the record that was just created. */
const scoped = skill.owliver.capabilities.filter(
(capability) => skill.owliver.responses[capability]?.context === 'positionId'
);
if (!scoped.length) continue;
const offered = skill.owliver.suggestions
.filter((s) => !s.capability || scoped.includes(s.capability))
.slice(0, 1)
.map((s) => ({
label: s.label,
/* Named, so the reading resolves against this position rather than
asking which one. */
prompt: `${s.prompt} for ${position.title}`,
skillId: skill.id,
skillCapability: s.capability || null,
}));
chips.push(...offered);
}
return chips.slice(0, 2);
}

View File

@@ -56,6 +56,26 @@ const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere els
* understood — which re-asks with `retry` rather than storing a guess.
*/
const FIELDS = {
/**
* The client this role is being staffed for.
*
* A "client" is not a record of its own in this product — it is the `company`
* on the position, which is the field the Create Position form writes, the
* Positions card leads with, and Hired History reports against. So the
* conversation collects it into the same field rather than into a store of
* its own, and asking Owliver to create a client starts here.
*/
company: {
label: 'Company',
settled: (draft) => Boolean(String(draft.company || '').trim()),
retry: 'Type the client or company name — "Fairmont San Jose".',
parse: (answer) => {
const value = cleanPhrase(answer, 60);
return value && value.length > 1 ? { company: titleCase(value) } : null;
},
summary: (draft) => (draft.company ? `Company: ${draft.company}` : null),
},
role_category: {
label: 'Role',
settled: (draft) => Boolean(draft.title),
@@ -233,10 +253,13 @@ function review(flow, steps) {
doc: doc(
text('Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Nothing is saved until you choose Create position.')
note('Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.')
),
/* The two the form offers, in the same words and the same order, so the
conversation and the page commit a position the same two ways. */
followUp: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Save as Draft', prompt: 'Save as draft' },
{ label: 'Publish Job Posting', prompt: 'Publish job posting' },
{ label: 'Change details', prompt: 'Change details' },
],
};
@@ -305,9 +328,15 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
/* The confirmation step. "Create position" is the only path to a record. */
if (flow.stage === 'review') {
if (/^(?:create position|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
/* Saving unpublished. The same write, with the status the form's own
"Save as Draft" button sets — one create path, two statuses. */
if (/^(?:save as draft|save draft|draft|save it as a draft)$/i.test(said)) {
if (missingRequired(flow, steps).length) return review(flow, steps);
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft } };
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'draft' } };
}
if (/^(?:create position|publish job posting|publish|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
if (missingRequired(flow, steps).length) return review(flow, steps);
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'active' } };
}
if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps);
@@ -322,7 +351,7 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
doc: doc(
text('I did not catch that. Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Choose Create position, or tell me what to change.')
note('Choose Save as Draft or Publish Job Posting, or tell me what to change.')
),
};
}
@@ -430,14 +459,19 @@ function applyStatement(flow, steps, said, roles) {
/** The position exists. Said plainly, with what was created. */
export function positionCreatedReply(position) {
const isDraft = position.status === 'draft';
return doc(
text('Position created successfully.'),
text(isDraft ? 'Saved as a draft.' : 'Position created successfully.'),
list([
position.company,
position.title,
position.location,
payLabel(position),
].filter(Boolean)),
note('It is on the Positions list now — applications will start appearing against it.')
note(isDraft
? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.'
: 'It is on the Positions list now — applications will start appearing against it.')
);
}
@@ -449,7 +483,20 @@ export function positionFailedReply() {
);
}
/** What the panel offers after a position is created. */
export const createdFollowUp = (position) => [
{ label: 'View position', route: `/admin/positions/${position.id}` },
];
/**
* What the panel offers after a position is created.
*
* A published role has an obvious next question — who can fill it — so it is
* offered here rather than left to be typed. The chip carries the position's
* own title and the wording the workforce engine already answers, so it is an
* ordinary question resolved by the path that was already there: no handler,
* no navigation, and the same answer as asking it by hand.
*/
export const createdFollowUp = (position) => (position.status === 'draft'
/* A draft is unfinished, so the way on is the form that finishes it — the
same route the Positions card's Continue uses. */
? [{ label: 'Continue to save', route: `/admin/positions/new?draft=${encodeURIComponent(position.id)}` }]
: [
{ label: 'View position', route: `/admin/positions/${position.id}` },
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
]);

View File

@@ -1,4 +1,8 @@
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
import { parseYaml } from './yaml';
import { normalizeSkillUi } from './uiConfig';
import { normalizeSkillOwliver } from './owliverConfig';
import { SUPPORTED_SKILL_PAGES, canonicalPage, surfaceFor, surfaceForRoute } from './surfaces';
/**
* Owliver skill registry.
@@ -16,43 +20,25 @@ import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: 'default', eager: true });
/**
* Frontmatter, parsed to the subset the format actually uses: `key: value` and
* `key:` followed by an indented `- item` list.
* Frontmatter, as data.
*
* Deliberately not a YAML library — the app has none, this needs no dependency,
* and a skill file that reaches for anchors or nested maps has outgrown being a
* declaration anyway.
* Skills grew declarative UI configuration, which is nested, so this reads the
* YAML subset in `yaml.js` rather than the flat `key: value` pairs it used to.
* The old shapes are a strict subset of the new one — a definition written for
* the previous parser parses identically here.
*
* A file whose frontmatter cannot be read raises rather than registering a
* half-understood definition; `parseSkill` decides what to do with that.
*/
function parseFrontmatter(raw) {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
if (!match) return { data: {}, body: raw };
const data = {};
let listKey = null;
for (const line of match[1].split(/\r?\n/)) {
if (!line.trim()) continue;
const item = /^\s*-\s+(.*)$/.exec(line);
if (item && listKey) {
data[listKey].push(item[1].trim());
continue;
}
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
if (!pair) continue;
const [, key, value] = pair;
if (value === '') {
listKey = key;
data[key] = [];
} else {
listKey = null;
data[key] = value.trim();
}
}
return { data, body: raw.slice(match[0].length).trim() };
const data = parseYaml(match[1]);
return {
data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
body: raw.slice(match[0].length).trim(),
};
}
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
@@ -131,10 +117,14 @@ function sectionLevels(body) {
/**
* The page key a route belongs to — `/admin/positions` → `positions`.
*
* Derived from the placement table rather than written down again, so a route
* added there is addressable by a skill without touching this file.
* The surface table answers first, because a surface already states its own
* route and its key is not always the path tail: `/admin/positions/new` is
* `create-position`, not `positions/new`. Falling back to the tail keeps every
* route that has no declared surface behaving exactly as it did.
*/
export function pageKeyForRoute(route) {
const surface = surfaceForRoute(route);
if (surface) return surface.id;
const tail = route.replace(/^\/admin\/?/, '');
return tail === '' ? 'control-center' : tail;
}
@@ -154,6 +144,59 @@ const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route,
export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null;
export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null;
/**
* The two management surfaces a definition can belong to.
*
* `ui` extends a KROW page; `owliver` extends the assistant. They share the
* parser, the registry, the validator, the persistence and the data resolver —
* only the authoring and management experience is separate, which is what this
* classification serves.
*/
export const SKILL_FACETS = ['ui', 'owliver'];
/**
* Which of them a definition belongs to.
*
* Declared, never configured: a `ui:` block is a page extension, and an
* `owliver:` block, triggers, actions, a prompt or a conversation is an
* assistant extension. A definition that declares neither is an assistant
* skill — that is what every definition written before the split was, and
* reading it any other way would drop it out of both lists.
*
* Workforce paths are neither. They define a capability the workforce holds
* and are managed in Skill Development, so they carry no facet and appear on
* neither list.
*/
export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation = [] }) {
if (kind === 'workforce') return [];
const extendsPage = Object.keys(ui).length > 0;
/**
* Behaviour Owliver actually gains: something to answer with, something to
* open, or questions to ask.
*
* Triggers alone are deliberately not on this list. A trigger is a way of
* being *named*, and a page-drawing definition that names itself is still a
* page-drawing definition — listing it as an Owliver skill would offer an
* author a capability list it never declared. A definition with no `ui:` is
* the other way round: triggers are all it has, and they are what it does.
*/
const teachesOwliver = Boolean(
data.owliver
|| (Array.isArray(data.actions) && data.actions.length)
|| data.prompt
|| conversation.length
|| owliver?.capabilities?.length
|| owliver?.suggestions?.length
);
return [
extendsPage ? 'ui' : null,
teachesOwliver || !extendsPage ? 'owliver' : null,
].filter(Boolean);
}
/**
* One Markdown definition → one skill.
*
@@ -179,9 +222,49 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
*/
const kind = data.kind || (levels.length ? 'workforce' : 'assistant');
/* The declarative UI this definition contributes, checked against the
closed vocabulary in `surfaces.js`. A definition with no `ui:` block is
exactly what it was before this existed. */
const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, {
declaredPages: pages,
skillId: id,
});
/* The same definition's second consumer. `owliver:` declares what can be
asked for in the panel, reading the source the page section already
names — so one file answers "what does this page show" and "what can
Owliver be asked here" without either being written twice. A definition
with no `owliver:` block is exactly what it was before this existed. */
const { owliver, errors: owliverErrors } = normalizeSkillOwliver(data.owliver, {
ui,
skillId: id,
skillName: data.name || '',
});
/* The questions this skill asks, when it collects its input in the chat
rather than by opening something. */
const conversation = sectionSteps(body, 'Conversation');
return {
id,
kind,
ui,
uiErrors,
owliver,
owliverErrors,
/**
* What this definition extends, derived from what it declares.
*
* Two things wear the same format and are managed as different lists: a
* definition with a `ui:` block extends a *page*, and one that teaches
* Owliver — an `owliver:` block, triggers, actions, a conversation —
* extends the *assistant*. Reading that off the declaration rather than
* off a `type:` field is what makes the split free: every definition
* already written classifies itself, nothing stored has to be migrated,
* and a definition doing both is listed in both places rather than
* losing half of itself to a category.
*/
facets: skillFacets({ data, kind, ui, owliver, conversation }),
name: data.name || 'Untitled skill',
description: data.description || '',
status: data.status === 'inactive' ? 'inactive' : 'active',
@@ -213,12 +296,21 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
? data.triggers
: [data.name].filter(Boolean)
).map((t) => String(t).toLowerCase()),
/**
* Whether those triggers were *claimed* or merely inherited.
*
* The fallback above is convenient and, until this field existed,
* indistinguishable from the real thing — so a definition that only draws
* a card was silently claiming its own name as a phrase Owliver answers
* to, and could take a question from a definition written to answer it.
* Keeping the distinction lets the matcher weigh a claim differently from
* a default without changing what `triggers` contains.
*/
declaredTriggers: Boolean(Array.isArray(data.triggers) && data.triggers.length),
prompt: data.prompt || null,
capabilities: sectionBullets(body, 'Capabilities'),
purpose: sectionBullets(body, 'Purpose'),
/* The questions this skill asks, when it collects its input in the chat
rather than by opening something. */
conversation: sectionSteps(body, 'Conversation'),
conversation,
path,
body,
custom,
@@ -255,23 +347,41 @@ export function allSkills(customSources = []) {
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/** Validates a definition before it is stored. Returns an error string or null. */
/**
* Validates a definition before it is stored. Returns an error string or null.
*
* Frontmatter first, then the declarative UI — an author is told the first
* thing that is wrong, in the order they would fix it.
*/
export function validateSkillSource(raw) {
if (!String(raw).trim()) return 'Paste or upload a Markdown definition.';
let skill;
try {
skill = parseSkill(raw, { custom: true });
} catch {
return 'That definition could not be parsed.';
} catch (error) {
/* The YAML subset reports the line it failed on; that is far more useful
than "could not be parsed". */
return `That definition could not be parsed. ${error.message || ''}`.trim();
}
if (!skill.id) return 'The frontmatter needs an `id`.';
if (!/^[a-z0-9][a-z0-9-]*$/.test(skill.id)) return '`id` must be lower-case letters, numbers and dashes.';
if (!skill.name) return 'The frontmatter needs a `name`.';
if (!skill.pages.length) return 'The frontmatter needs at least one `pages` entry.';
const unknown = skill.pages.filter((p) => !ROUTE_BY_PAGE_KEY[p]);
const unknown = skill.pages.filter((p) => !surfaceFor(p));
if (unknown.length) {
return `Unknown page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Known pages: ${Object.keys(ROUTE_BY_PAGE_KEY).join(', ')}.`;
return `Unsupported page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`;
}
/* A UI block that names something the product does not offer is refused
outright rather than registered with the offending section dropped. */
if (skill.uiErrors?.length) return skill.uiErrors[0];
/* Same rule for the panel half of the definition: a capability, source or
step the product cannot honour is refused now rather than registered as
something Owliver offers and then cannot answer. */
if (skill.owliverErrors?.length) return skill.owliverErrors[0];
return null;
}
@@ -280,7 +390,10 @@ export const PAGE_KEYS = Object.keys(ROUTE_BY_PAGE_KEY).sort();
/** Context ids a skill applies to, resolved through the placement table. */
export function contextIdsForSkill(skill) {
return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean);
return skill.pages
.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId
|| ROUTE_BY_PAGE_KEY[pageKeyForRoute(surfaceFor(key)?.route || '')]?.contextId)
.filter(Boolean);
}
/**
@@ -297,10 +410,11 @@ export function contextIdsForSkill(skill) {
*/
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
if (!pageId) return [];
const wanted = canonicalPage(pageId) || pageId;
return allSkills(customSources).filter(
(s) => s.status === 'active'
&& !disabled.includes(s.id)
&& s.pages.includes(pageId)
&& s.pages.some((p) => (canonicalPage(p) || p) === wanted)
&& (!kind || s.kind === kind)
);
}
@@ -338,10 +452,40 @@ function triggerMatches(trigger, question) {
return new RegExp(pattern).test(question);
}
/** The first skill on this page whose triggers match the question. */
/**
* The first skill on this page whose triggers match the question.
*
* A definition has to be *addressable by the assistant* before its triggers
* count, and there are two ways to be: teach Owliver something — an `owliver:`
* block, an action, a conversation — or explicitly claim a phrase with
* `triggers:`. A definition that does neither is a page extension that happens
* to have a name, and matching it here means answering a question with a
* restatement of a card's description.
*
* That was live: `hiring-activity` draws a flow on the Positions page, declares
* no triggers, and inherited "hiring activity" from its own name — enough to
* take "show hiring activity" from the definition written to answer it.
*
* Both conditions are read off what the definition declares, so a skill written
* tomorrow is admitted or excluded by the same rule, and nothing that claimed a
* phrase loses it.
*/
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
export function matchSkill(question, contextId, disabled = [], customSources = []) {
const q = String(question).toLowerCase();
return skillsForContext(contextId, disabled, customSources).find(
(s) => s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
) ?? null;
}
/**
* The definitions belonging to one management surface.
*
* The single answer to "what belongs on the UI Skills list" and "what belongs
* on the Owliver Skills list". Both lists come from `allSkills` — one registry,
* two readings of it — so a definition cannot exist on one list and be unknown
* to the other system.
*/
export const skillsWithFacet = (skills = [], facet) =>
skills.filter((s) => s.facets?.includes(facet));

478
src/lib/skills/surfaces.js Normal file
View File

@@ -0,0 +1,478 @@
/**
* What a skill definition is allowed to say about the product.
*
* A skill can extend a KROW page: name a surface, declare a section, and the
* page renders it. That only stays safe — and only stays a *product* rather
* than a scripting host — because the vocabulary is closed. Everything a
* definition may name is in this file: the surfaces, the placements on each
* surface, the component types, and the data sources.
*
* The rule that makes it safe: **nothing here is code, and nothing here is
* looked up dynamically from the file.** A definition names a key; this module
* says whether that key exists; the renderer maps it to a component the app
* already ships. A definition that names something absent is rejected with a
* message, never rendered as an unknown thing and never executed.
*/
/**
* The surfaces a skill can extend.
*
* `aliases` keep the page keys the existing skills already use — `hired`,
* `university` — working under the names the product now shows, so the eight
* definitions on disk did not have to be rewritten to gain this feature.
*/
export const SKILL_SURFACES = [
{
id: 'control-center',
label: 'Control Center',
route: '/admin',
placements: ['after-header', 'before-footer'],
},
{
id: 'positions',
label: 'Positions',
route: '/admin/positions',
/* Mounted in three places, because Positions is three experiences: the page
that lists the roles, the card for each one, and the drawer "View
position" opens.
`after-position-list-summary` and `after-position-list` are the *page*:
they render once, above and below the grid, with no position in context.
`after-position-card` renders inside each card. The rest render inside
the drawer and on the full position page, where one position is being
read. Every one of these is mounted — a placement the vocabulary offers
and no page provides is a definition that validates and then silently
does nothing. */
placements: [
'after-position-list-summary',
'after-position-list',
'after-header',
'after-position-card',
'after-position-summary',
'before-candidates',
'after-candidates',
'before-footer',
],
},
{
/* The authoring form, which is a surface in its own right: what a skill has
to say there is about the position being specified, not about the ones
that already exist. Its placements follow the form's own three parts, so
a definition can sit beside the field group it is about. */
id: 'create-position',
label: 'Create Position',
route: '/admin/positions/new',
aliases: ['new-position'],
placements: [
'after-header',
'after-job-description',
'after-vetting-weights',
'before-footer',
],
},
{
id: 'candidates',
label: 'Candidates',
route: '/admin/candidates',
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
},
{
id: 'hired-history',
label: 'Hired History',
route: '/admin/hired',
aliases: ['hired'],
placements: ['after-header', 'before-footer'],
},
{
id: 'talent-pool',
label: 'Talent Pool',
route: '/admin/talent-pool',
placements: ['after-header', 'before-footer'],
},
{
id: 'krow-forge',
label: 'KROW Forge',
route: '/admin/university',
aliases: ['university', 'forge'],
placements: ['after-header', 'before-footer'],
},
{
id: 'analytics',
label: 'Analytics',
route: '/admin/analytics',
placements: ['after-header', 'before-footer'],
},
{
id: 'activity',
label: 'Activity',
route: '/admin/activity',
placements: ['after-header', 'before-footer'],
},
/* Not in the eight product surfaces, but skills already attach to it and the
account page reads them. Kept so nothing that works today stops working. */
{
id: 'profile',
label: 'Profile',
route: '/admin/profile',
placements: ['after-header', 'before-footer'],
},
{
id: 'candidates-analysis',
label: 'Candidate Analysis',
route: '/admin/candidates-analysis',
placements: ['after-header', 'before-footer'],
},
];
const BY_KEY = new Map();
for (const surface of SKILL_SURFACES) {
BY_KEY.set(surface.id, surface);
for (const alias of surface.aliases || []) BY_KEY.set(alias, surface);
}
/** Every name a definition may use for a surface, for error messages. */
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
/** The surface a declared page name refers to, or null. */
export const surfaceFor = (page) => BY_KEY.get(String(page || '').trim()) || null;
/** The canonical id for a declared page name — `hired` → `hired-history`. */
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
/**
* The surface an Admin route belongs to — `/admin/positions/new` →
* `create-position`.
*
* The surfaces already carry their routes, so this reads the answer off the
* table rather than deriving a page key from the path a second time. That
* matters for the surfaces whose key is not their path tail: without it,
* `/admin/positions/new` would key as `positions/new`, which is a page nothing
* declares and no definition could attach to.
*/
export const surfaceForRoute = (route) =>
SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null;
/**
* The section types a definition may ask for.
*
* Each entry names a component the application already ships. A type is a key
* in this table and nothing else: there is no path from a definition to a
* component that is not listed here, which is what stops `type:` from being an
* import statement in disguise.
*/
export const SECTION_TYPES = [
{ id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' },
{ id: 'stats', label: 'Stats', summary: 'A row of counted figures.' },
{ id: 'list', label: 'List', summary: 'A ranked or plain list of records.' },
{ id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' },
{ id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' },
{ id: 'table', label: 'Table', summary: 'Rows and columns.' },
{ id: 'progress', label: 'Progress', summary: 'Bars against a total.' },
{ id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' },
/* Weighted criteria that share a budget. Distinct from `progress`, which is
bars against an independent maximum: these bars are shares of one total,
and the total is a fact about the set rather than about any one row. */
{ id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' },
];
export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id);
/**
* What a definition may ask *Owliver* to do with the same reading.
*
* A skill declares `ui:` for the page and `owliver:` for the panel, and both
* name the same data source. A capability is the second half of that: the shape
* the answer takes when it is asked for in conversation rather than rendered on
* the page.
*
* `shape` is the section type the answer is drawn with, so `flow` in a chat
* reply is the *same* component the page renders — there is one flow renderer,
* not one per consumer. `summary` has no shape because a summary is prose: the
* figures are read back as sentences rather than drawn.
*
* `terms` are how a question is recognised as asking for this shape. They are
* deliberately about the *shape* and never about a subject: "as a flow" belongs
* here, "hiring activity" belongs in a definition's `triggers`. That split is
* what keeps this table closed while the skills stay open.
*/
export const OWLIVER_CAPABILITIES = [
{
id: 'summary',
label: 'Summary',
shape: null,
summary: 'Reads the figures back as sentences.',
terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising',
'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about',
'what is the', 'how is'],
},
{
id: 'flow',
label: 'Flow',
shape: 'flow',
summary: 'Draws the stages or periods as a sequence.',
terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual',
'visualise', 'visualize', 'step by step'],
},
{
id: 'stats',
label: 'Stats',
shape: 'stats',
summary: 'A row of counted figures.',
terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'],
},
{
id: 'list',
label: 'List',
shape: 'list',
summary: 'A ranked or plain list of records.',
terms: ['list', 'who are', 'which ones', 'show me the records'],
},
{
id: 'table',
label: 'Table',
shape: 'table',
summary: 'Rows and columns.',
terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'],
},
{
id: 'timeline',
label: 'Timeline',
shape: 'timeline',
summary: 'Dated events, most recent first.',
terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'],
},
{
id: 'progress',
label: 'Progress',
shape: 'progress',
summary: 'Bars against a total.',
terms: ['progress', 'bars', 'completion', 'how far'],
},
{
id: 'weights',
label: 'Weights',
shape: 'weights',
summary: 'The weighted criteria, adjustable when the page accepts the write.',
terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance',
'set the weights', 'adjust the weights', 'screening weight', 'vetting weight',
'criteria'],
},
{
id: 'insight',
label: 'Insight',
shape: 'insight',
summary: 'One finding, stated plainly.',
terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'],
},
{
id: 'card',
label: 'Card',
shape: 'card',
summary: 'A titled panel of figures.',
terms: ['card', 'panel', 'at a glance'],
},
];
export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id);
/** The capability a declared name refers to, or null. */
export const owliverCapabilityFor = (id) =>
OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null;
/** `flow` → `Flow`. */
export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id;
/**
* The data a section may ask for.
*
* Each source is a named reading of data the application already holds, and
* `context` says what a page must know for the reading to be possible — a
* position id, a candidate id, or nothing. A source is resolved by
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
* and cannot name a field that is not offered here.
*/
export const DATA_SOURCES = [
{
id: 'position.activity',
label: 'Position activity',
context: 'positionId',
summary: 'Applications to this position, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
},
{
id: 'position.pipeline',
label: 'Position pipeline',
context: 'positionId',
summary: 'Applied → screened → shortlisted → interviewed → hired.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
},
{
id: 'position.candidates',
label: 'Position candidates',
context: 'positionId',
summary: 'Candidates matched to this position, best first.',
shapes: ['list', 'table', 'stats', 'card'],
},
{
/**
* Who could actually do this work, ranked.
*
* Distinct from `position.candidates`, which lists the people who *applied
* here*. This one reads the whole candidate pool against what the position
* states — so a role published a minute ago, with no applications at all,
* still has an answer. The reading is `poolFor` in `lib/workforce.js`, the
* same engine the panel's workforce conversation and the position page use;
* nothing about matching is decided in the resolver.
*/
id: 'position.matches',
label: 'Position candidate matches',
context: 'positionId',
summary: 'The candidate pool scored against this position, best first.',
shapes: ['list', 'table', 'stats', 'card', 'insight'],
},
{
id: 'position.requirements',
label: 'Position requirements',
context: 'positionId',
summary: 'What this position states it needs.',
shapes: ['list', 'table', 'card'],
},
{
id: 'candidate.readiness',
label: 'Candidate readiness',
context: 'candidateId',
summary: 'Screening dimensions for one candidate.',
shapes: ['progress', 'stats', 'list', 'table', 'card'],
},
{
id: 'candidate.activity',
label: 'Candidate activity',
context: 'candidateId',
summary: 'What has happened on this candidate’s record.',
shapes: ['timeline', 'list', 'table', 'card'],
},
{
id: 'candidates.pipeline',
label: 'Candidate pipeline',
context: null,
summary: 'Every candidate, counted by stage.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
},
{
id: 'candidates.activity',
label: 'Candidate activity',
context: null,
summary: 'Applications across the workspace, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
},
{
id: 'positions.demand',
label: 'Position demand',
context: null,
summary: 'Open positions and what they still need.',
shapes: ['list', 'table', 'stats', 'card'],
},
{
id: 'workforce.training',
label: 'Workforce training',
context: null,
summary: 'Training paths and progress against them.',
shapes: ['progress', 'list', 'stats', 'table', 'card'],
},
{
/* The vetting weights a position is being specified with. `positionId`
context, but the "position" on Create Position is the draft in the form
rather than a saved record — which is the point: the resolver reads the
same field either way, so one source serves the form and the position it
becomes. */
id: 'position.vetting',
label: 'Position vetting weights',
context: 'positionId',
summary: 'How this position weights each screening criterion.',
shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'],
/**
* This reading can be written back.
*
* `writable` is what lets a definition declare `editable: true` and get
* controls instead of a read-out. It is a property of the *source*, not of
* the definition — a skill cannot make a reading writable by asking, and a
* source with no page willing to accept the write simply renders read-only.
* That keeps the closed vocabulary closed in both directions.
*/
writable: true,
writeSummary: 'Sets the screening weights on the position being specified.',
},
{
id: 'hires.recent',
label: 'Recent hires',
context: null,
summary: 'Who was hired, for which role, and when.',
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
},
{
id: 'hires.performance',
label: 'Hiring performance',
context: null,
summary: 'Hires, time-to-hire, quality and conversion, counted together.',
shapes: ['stats', 'card', 'table', 'flow', 'insight'],
},
{
id: 'activity.events',
label: 'Workspace activity',
context: null,
summary: 'What has happened across the workspace, most recent first.',
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
},
];
export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
/**
* Can this reading be written back?
*
* The one question `editable:` is checked against. A page opts in by publishing
* a handler for the source (see `usePublishPageActions`); a definition opts in
* by declaring `editable: true`. Both have to hold before a control is drawn,
* so neither the author nor the page can enable editing on its own.
*/
export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable);
/**
* The periods a time-based section may ask for.
*
* Only the ones the data layer can actually compute from record timestamps.
* A period is a window over `created_date`, resolved at read time against the
* current date — never a stored figure and never a hard-coded date.
*/
export const PERIODS = [
{ id: 'today', label: 'Today' },
{ id: 'yesterday', label: 'Yesterday' },
{ id: 'last-7-days', label: 'Last 7 days' },
{ id: 'last-week', label: 'Last week' },
{ id: 'this-month', label: 'This month' },
{ id: 'previous-month', label: 'Previous month' },
];
export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id);
export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id;
/* ── Human labels ───────────────────────────────────────────────────────────
The vocabulary is written in kebab-case because it is configuration; it is
read by people, so every id has a label. One place, so the preview, the
rendered section and any future surface all say the same words. */
/** `flow` → `Flow`. */
export const sectionTypeLabel = (id) =>
SECTION_TYPES.find((t) => t.id === id)?.label || id;
/** `position.activity` → `Position activity`. */
export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id;
/** `after-position-summary` → `After position summary`. */
export const placementLabel = (id) => {
const words = String(id || '').replace(/-/g, ' ').trim();
return words ? words[0].toUpperCase() + words.slice(1) : id;
};

293
src/lib/skills/uiConfig.js Normal file
View File

@@ -0,0 +1,293 @@
import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor,
} from './surfaces';
/**
* The `ui:` block of a skill definition, checked and normalized.
*
* A definition declares what it wants; this decides whether the product can
* honour it, and turns a loose YAML shape into one predictable record. Two
* things follow from doing it here rather than in the renderer:
*
* - **Nothing unknown reaches a component.** Every page, placement, type,
* data source and period is checked against the closed vocabulary in
* `surfaces.js`. An unrecognised value is an error with a message naming
* it, not a silently dropped key and not a rendered blank.
* - **Only listed keys survive.** The normalized section carries exactly the
* fields the renderers read. Anything else an author writes is ignored
* rather than passed through, so no property can arrive at React that this
* module did not put there.
*/
/**
* A section as the renderers receive it. Nothing else is carried.
*
* Used by both consumers of a definition. The page passes a `page`, so the
* section is checked against that surface's placements; Owliver passes
* `placement: false`, because a chat reply has no placement to sit at — the
* rest of the checks, and the record that comes out, are identical. That is
* deliberate: it is what makes a flow drawn in the panel the same section as
* the flow drawn on the page rather than a parallel shape that resembles it.
*
* `types` narrows what `type:` may be. The page offers the components it can
* mount; Owliver offers those plus the shapes that are only answers.
*/
export function normalizeSection(raw, {
page, errors, seen, fallbackId = '', where: label = null,
placement: wantPlacement = true, types = SUPPORTED_SECTION_TYPES,
shapeFor = (type) => type,
}) {
const where = label || `ui.${page}`;
/* Where an error points. A page section is addressed by the id it was given;
a capability response is already addressed by the capability it answers, so
appending a generated section id there would name something the author
never wrote. */
const at = label ? where : null;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
errors.push(`${where}: each section must be a mapping of options.`);
return null;
}
/* An id is how a section is keyed and de-duplicated, not something an author
should have to invent for a definition that declares exactly one. Falls
back to the title, then to the skill's own id. */
const slug = (value) => String(value || '')
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const id = slug(raw.id) || slug(raw.title) || slug(fallbackId);
if (!id) {
errors.push(`${where}: a section needs an \`id\`.`);
return null;
}
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
errors.push(`${at || `${where}.${id}`}: \`id\` must be lower-case letters, numbers and dashes.`);
return null;
}
if (seen.has(id)) {
errors.push(`${where}: two sections share the id \`${id}\`.`);
return null;
}
seen.add(id);
const type = String(raw.type || '').trim();
if (!type) {
errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`);
return null;
}
if (!types.includes(type)) {
errors.push(
`Unsupported skill component: ${type}. Supported types: ${types.join(', ')}.`
);
return null;
}
/* `position:` is what the format calls it; `placement:` is accepted because
it is the word the rest of the system uses. A section that is an answer
rather than a panel has nowhere to be placed, and says so with `null`. */
let placement = null;
if (wantPlacement) {
const surface = surfaceFor(page);
const declaredPlacement = String(raw.position || raw.placement || '').trim();
placement = declaredPlacement || surface.placements[0];
if (!surface.placements.includes(placement)) {
errors.push(
`Unsupported placement: ${placement} on ${page}. Supported placements: ${surface.placements.join(', ')}.`
);
return null;
}
}
/* `data.source:` and a flat `source:` mean the same thing. The nested form
groups options when a section grows; the flat form is what a one-section
definition actually reads like, and refusing it would be the format being
precious about punctuation. */
const source = String(raw.data?.source ?? raw.source ?? '').trim();
if (!source) {
errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`);
return null;
}
if (!SUPPORTED_DATA_SOURCES.includes(source)) {
errors.push(
`Unsupported data source: ${source}. Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.`
);
return null;
}
/* A source knows which shapes it can fill. Asking for a timeline of something
that has no dates is an authoring mistake worth naming now rather than
rendering as an empty panel later. */
const definition = dataSourceFor(source);
/* `shapeFor` is how a type that is not a drawn component is exempted: a
summary is prose, so there is no shape for a source to be incompatible
with. Every drawn type maps to itself. */
const shape = shapeFor(type);
if (shape && definition.shapes && !definition.shapes.includes(shape)) {
errors.push(
`${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.`
);
return null;
}
const periods = Array.isArray(raw.periods) ? raw.periods.map((p) => String(p).trim()) : [];
const unknownPeriod = periods.find((p) => !SUPPORTED_PERIODS.includes(p));
if (unknownPeriod) {
errors.push(
`Unsupported period: ${unknownPeriod}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.`
);
return null;
}
const limit = Number(raw.limit);
/**
* Whether this section offers controls rather than a read-out.
*
* Two independent things must agree before anything is editable, and this is
* the first: the definition asking for it, and the source declaring that it
* can be written at all. The second is the page publishing a handler for that
* source at render time. A definition asking to edit a reading the product
* does not expose for writing is an authoring mistake worth naming here,
* rather than a control that silently does nothing.
*/
const editable = raw.editable === true || raw.editable === 'true';
if (editable && !isSourceWritable(source)) {
errors.push(
`${at || `${where}.${id}`}: \`${source}\` cannot be edited. It is a reading, not a setting.`
);
return null;
}
return {
id,
title: String(raw.title || '').trim() || null,
description: String(raw.description || '').trim() || null,
type,
placement,
source,
/* Declared intent only. A section stays read-only wherever no page offers
to accept the write — see `SkillSurface` and `SkillSectionBlock`. */
editable,
/* Context the page must supply for this source to resolve. */
context: definition.context,
periods,
limit: Number.isFinite(limit) && limit > 0 ? Math.min(50, Math.round(limit)) : null,
};
}
/**
* The keys that mean "this object *is* a section".
*
* How the two shapes are told apart. A definition may write its UI either way:
*
* ui: ui:
* type: flow positions:
* placement: … sections:
* source: … - type: flow
*
* The first is one section applying to every page the skill declares; the
* second addresses pages by name and can differ per page. Both are legitimate,
* and the difference is structural — an object carrying `type` or `source` is a
* section, an object whose keys are page names is a page map. Nothing is
* decided by a skill id, and neither shape is privileged.
*/
const SECTION_KEYS = new Set([
'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods',
'limit', 'editable',
]);
const looksLikeSection = (value) =>
Boolean(value)
&& typeof value === 'object'
&& !Array.isArray(value)
&& Object.keys(value).some((key) => SECTION_KEYS.has(key));
/** The sections a page entry declares, in either the list or single-section form. */
function sectionsOf(config) {
if (Array.isArray(config?.sections)) return config.sections;
if (Array.isArray(config)) return config;
if (looksLikeSection(config)) return [config];
return null;
}
/**
* The whole `ui:` block, normalized per page.
*
* Returns `{ ui, errors }`. `ui` holds only what validated, so a definition with
* one bad section still registers its good ones — and the author still sees why
* the other was refused.
*/
export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {}) {
const errors = [];
const ui = {};
if (rawUi == null) return { ui, errors };
if (typeof rawUi !== 'object' || Array.isArray(rawUi)) {
return { ui, errors: ['`ui` must be a section, or a mapping of page names to sections.'] };
}
const pages = declaredPages.map(canonicalPage).filter(Boolean);
/* Shorthand: one section, applied to every page the skill declares. The keys
inside it are section options and are never read as page names — which is
exactly what this branch exists to prevent. */
if (looksLikeSection(rawUi)) {
if (!pages.length) {
return { ui, errors: ['`ui` is configured but the skill declares no `pages`.'] };
}
for (const page of pages) {
const section = normalizeSection(rawUi, {
page, errors, seen: new Set(), fallbackId: skillId,
});
if (section) ui[page] = { sections: [section] };
}
return { ui, errors };
}
/* Otherwise every key is a page name. */
for (const [rawPage, config] of Object.entries(rawUi)) {
const page = canonicalPage(rawPage);
if (!page) {
errors.push(
`Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`
);
continue;
}
/* A page cannot be extended unless the skill also declares it. Otherwise a
definition could render onto a surface it never said it applied to. */
if (pages.length && !pages.includes(page)) {
errors.push(`\`ui.${rawPage}\` is configured but \`${rawPage}\` is not listed under \`pages\`.`);
continue;
}
const rawSections = sectionsOf(config);
if (!rawSections) {
errors.push(`ui.${rawPage}: expected a \`sections\` list, or a single section.`);
continue;
}
const seen = new Set();
const sections = rawSections
.map((section) => normalizeSection(section, {
page: rawPage, errors, seen, fallbackId: skillId,
}))
.filter(Boolean);
if (sections.length) ui[page] = { sections };
}
return { ui, errors };
}
/** Every section this skill contributes to a page, in declaration order. */
export const sectionsForPage = (skill, page) => {
const key = canonicalPage(page);
return key ? skill?.ui?.[key]?.sections || [] : [];
};
/** How many UI sections a definition registers, across every page. */
export const countSections = (skill) =>
Object.values(skill?.ui || {}).reduce((n, page) => n + (page.sections?.length || 0), 0);

174
src/lib/skills/yaml.js Normal file
View File

@@ -0,0 +1,174 @@
/**
* The YAML subset skill frontmatter is allowed to use.
*
* Skills gained declarative UI configuration, which is nested — `ui:` holds a
* page, which holds sections, which hold their own options. The old parser read
* flat `key: value` pairs and one level of `- item`, so nesting was impossible
* to express.
*
* This is deliberately a *subset*, not a YAML library:
*
* - block maps and block sequences, nested to any depth
* - scalars: strings, integers, floats, booleans, null
* - quoted strings, for values containing `:` or `#`
* - `- key: value` — a mapping that starts on the dash
* - `#` comments, and blank lines
*
* Everything else — anchors, aliases, merge keys, multi-document files, flow
* mappings, block scalars, tags — is not supported and is not silently
* half-read: an unparseable line raises, so a definition either means what it
* says or is rejected with a line number.
*
* It returns plain data and nothing else. There is no code path from this file
* to evaluation of any kind: no `eval`, no `Function`, no dynamic import, no
* JSON with a reviver. A skill definition is configuration, and this is the
* boundary that keeps it configuration.
*/
/** `true` / `false` / `null` / numbers, or the string as written. */
function toScalar(raw) {
const value = String(raw).trim();
if (value === '' || value === '~' || value === 'null') return null;
if (value === 'true') return true;
if (value === 'false') return false;
/* Quoted: taken literally, which is how a value containing `:` or `#` is
written. No escape processing beyond the doubled quote. */
const quoted = /^(['"])([\s\S]*)\1$/.exec(value);
if (quoted) return quoted[2].replace(new RegExp(quoted[1] + quoted[1], 'g'), quoted[1]);
if (/^-?\d+$/.test(value)) return Number(value);
if (/^-?\d*\.\d+$/.test(value)) return Number(value);
/* An unquoted trailing comment is a comment. `#` inside a word is not. */
return value.replace(/\s+#.*$/, '').trim();
}
/** One line, reduced to what the parser needs to decide. */
function readLines(source) {
return String(source)
.split(/\r?\n/)
.map((text, i) => ({ text, line: i + 1 }))
.filter(({ text }) => text.trim() !== '' && !/^\s*#/.test(text))
.map(({ text, line }) => ({
line,
indent: text.match(/^\s*/)[0].replace(/\t/g, ' ').length,
content: text.trim(),
}));
}
/**
* Parses one block at `indent` or deeper, starting at `cursor.i`.
*
* Returns a map or an array depending on what the first line at this level is,
* which is how YAML itself decides. Recursion handles nesting; the cursor is
* shared so a child can consume the lines it owns.
*/
function parseBlock(lines, cursor, indent) {
const first = lines[cursor.i];
if (!first) return null;
return first.content.startsWith('- ')
|| first.content === '-'
? parseSequence(lines, cursor, indent)
: parseMapping(lines, cursor, indent);
}
function parseSequence(lines, cursor, indent) {
const out = [];
while (cursor.i < lines.length) {
const { content, indent: at, line } = lines[cursor.i];
if (at < indent) break;
if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
if (!content.startsWith('-')) break;
const rest = content.replace(/^-\s*/, '');
cursor.i += 1;
if (rest === '') {
/* `-` alone: the item is the indented block beneath it. */
out.push(cursor.i < lines.length && lines[cursor.i].indent > indent
? parseBlock(lines, cursor, lines[cursor.i].indent)
: null);
continue;
}
/* `- key: value` opens a mapping whose first key sits on the dash. The
remaining keys are indented to where that key started. */
const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(rest);
if (pair) {
const keyIndent = indent + (content.length - rest.length);
const item = {};
const [, key, value] = pair;
item[key] = value === ''
&& cursor.i < lines.length
&& lines[cursor.i].indent > indent
? parseBlock(lines, cursor, lines[cursor.i].indent)
: toScalar(value);
while (cursor.i < lines.length && lines[cursor.i].indent === keyIndent
&& !lines[cursor.i].content.startsWith('- ')) {
Object.assign(item, parseMapping(lines, cursor, keyIndent));
}
out.push(item);
continue;
}
out.push(toScalar(rest));
}
return out;
}
function parseMapping(lines, cursor, indent) {
const out = {};
while (cursor.i < lines.length) {
const { content, indent: at, line } = lines[cursor.i];
if (at < indent) break;
if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
if (content.startsWith('- ')) break;
const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(content);
if (!pair) throw new Error(`Line ${line} is not \`key: value\`: ${content}`);
const [, key, value] = pair;
cursor.i += 1;
if (value !== '') {
out[key] = toScalar(value);
continue;
}
/* An empty value means the value is the block below — or nothing. */
const next = lines[cursor.i];
out[key] = next && next.indent > indent
? parseBlock(lines, cursor, next.indent)
: null;
}
return out;
}
/**
* A YAML document, as plain data.
*
* Throws on anything it cannot read rather than guessing, so a malformed
* definition is reported to its author instead of being registered in a shape
* nobody intended.
*/
export function parseYaml(source) {
const lines = readLines(source);
if (!lines.length) return {};
const cursor = { i: 0 };
const value = parseBlock(lines, cursor, lines[0].indent);
if (cursor.i < lines.length) {
throw new Error(`Unexpected indentation on line ${lines[cursor.i].line}`);
}
return value;
}

View File

@@ -1,11 +1,13 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide-react';
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useJobPosting, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel';
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { usePublishPageActions, usePublishPageContext } from '@/components/ai-assistant';
/**
* Create a Position.
@@ -38,9 +40,32 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
const [showWeights, setShowWeights] = useState(false);
const allCategories = [...new Set([...ROLE_CATEGORIES, ...customCategories.map(c => c.name)])];
const [draftId, setDraftId] = useState(null);
/**
* Continuing a draft.
*
* `?draft=<id>` is how the Positions list hands an unfinished position back to
* the form that was writing it. It is the same route, the same component and
* the same state as writing a new one — the only difference is that the form
* starts from the saved record and saves back to it, so nothing the author
* already entered is asked for twice and no second editor exists.
*/
const [searchParams] = useSearchParams();
const continuingId = searchParams.get('draft');
const { data: continuing } = useJobPosting(continuingId);
const [draftId, setDraftId] = useState(continuingId || null);
const [loadedId, setLoadedId] = useState(null);
const [aiResult, setAiResult] = useState(null);
/* Seeded once per record, not on every render of it: after this the form owns
the values, and re-seeding would discard whatever has been typed since. */
if (continuing && continuing.id !== loadedId) {
setLoadedId(continuing.id);
setDraftId(continuing.id);
setForm({ ...defaultPosition(), ...continuing });
}
const update = (field, value) => setForm({ ...form, [field]: value });
const toggleCert = (cert) => {
@@ -55,6 +80,46 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
const totalWeight = Object.values(form.vetting_criteria).reduce((a, b) => a + b, 0);
/* What a skill on this surface reads: the position being specified, as it
stands right now. The draft carries the same fields a saved position does,
so one declared section reports the form and the record it becomes.
Memoized on the form so the sections re-resolve on a change rather than on
every render. */
const skillContext = React.useMemo(() => ({ position: form }), [form]);
/* The same draft, published to the panel beside the form. A skill declaring
`create-position` is available to both consumers, and both read this one
record — so "summarize the vetting weights" answers about the weights on
screen, updating as they are edited. */
usePublishPageContext(skillContext);
/**
* The write that goes with that read.
*
* Owliver can already see the weights on this form; this is what lets it set
* them. The handler is the form's own setter — there is no second copy of the
* weights anywhere, so a change made in the panel and a change made on the
* slider below are the same change, and the section further down this page
* re-renders from the same state either way.
*
* Keyed by the data source rather than by a skill or an invented action name:
* any definition reading `position.vetting` can offer to set it, and no
* definition can write anywhere it cannot read. A functional update keeps the
* handler stable, so the published map is memoized once rather than rebuilt as
* the form is typed into.
*/
const applyVettingWeights = React.useCallback((next) => {
setForm((previous) => ({
...previous,
vetting_criteria: { ...previous.vetting_criteria, ...next },
}));
toast.success('Vetting weights updated');
}, []);
usePublishPageActions(
React.useMemo(() => ({ 'position.vetting': applyVettingWeights }), [applyVettingWeights])
);
const handleGenerateDescription = async () => {
if (!form.title) {
toast.error('Please enter a job title first');
@@ -107,7 +172,11 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
if (onDone) {
onDone(createdId);
} else if (status === 'active' && createdId) {
navigate(`/admin/positions/${createdId}`);
/* Back to the board, with the published role named in the URL. The list
is where the position now lives, and it is the surface Owliver sits
beside — so the role that was just published is the one the panel is
reasoning about, and offering candidates for, on arrival. */
navigate(`/admin/positions?published=${encodeURIComponent(createdId)}`);
} else {
navigate('/admin/positions');
}
@@ -141,6 +210,12 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
<h2 className="text-[16px] font-semibold text-[#111827]">Create Job Posting</h2>
</div>
{/* Extension points. A definition naming `create-position` and one of
these placements renders here, against the draft in the form. The
form knows no skill; the definition names the slot. Nothing below is
moved or replaced — the sections sit between what is already here. */}
<SkillSurface page="create-position" placement="after-header" context={skillContext} />
{/* Row 1 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -312,6 +387,12 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
</div>
)}
<SkillSurface
page="create-position"
placement="after-job-description"
context={skillContext}
/>
{/* AI Vetting Weights */}
<div className="border-t border-[#E5E7EB] pt-4">
<button onClick={() => setShowWeights(!showWeights)} className="w-full flex items-center justify-between">
@@ -340,6 +421,18 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
)}
</div>
<SkillSurface
page="create-position"
placement="after-vetting-weights"
context={skillContext}
/>
<SkillSurface
page="create-position"
placement="before-footer"
context={skillContext}
/>
{/* Bottom Actions */}
<div className="grid grid-cols-2 gap-3 pt-2">
<button onClick={() => handleSave('draft')} disabled={createJob.isPending || updateJob.isPending} className="border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">

View File

@@ -8,6 +8,7 @@ import {
PositionCustomRequirements, PositionOverview, PositionRequirements,
} from '@/components/krow/PositionDetails';
import { workforceStatusFor } from '@/lib/workforce';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { MatchedCandidates } from '@/components/krow/MatchedCandidates';
import { base44 } from '@/api/base44Client';
import CandidateCard from '@/components/krow/CandidateCard';
@@ -284,6 +285,8 @@ export default function PositionDetail() {
‹ Back to Positions
</button>
<SkillSurface page="positions" placement="after-header" context={{ position: job }} />
{/* Header Card */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
@@ -410,6 +413,13 @@ export default function PositionDetail() {
)}
</div>
{/* Extension points. A skill definition naming `positions` and one of
these placements renders here; with none registered they render
nothing at all. No page code changes when a skill is added. Every
placement the vocabulary offers for this surface is mounted, so a
valid definition can never point at a slot that does not exist. */}
<SkillSurface page="positions" placement="after-position-summary" context={{ position: job }} />
<WorkforceStatus workforce={workforce} job={job} />
{/* Which people can actually do this work, scored against the position's
@@ -459,6 +469,8 @@ export default function PositionDetail() {
</div>
)}
<SkillSurface page="positions" placement="before-candidates" context={{ position: job }} />
{/* Applicants */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
@@ -497,6 +509,9 @@ export default function PositionDetail() {
)}
</div>
<SkillSurface page="positions" placement="after-candidates" context={{ position: job }} />
<SkillSurface page="positions" placement="before-footer" context={{ position: job }} />
{/* Modals */}
{interviewApp && <AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={job} />}
{scheduleApp && <ScheduleInterviewModal open={true} onClose={() => setScheduleApp(null)} application={scheduleApp} />}

View File

@@ -43,20 +43,20 @@ export default function Positions() {
{/* Action Bar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9CA3AF]" />
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9CA3AF]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search positions..."
className="w-full h-10 pl-10 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#2563EB] focus:outline-none"
className="w-full h-9 pl-10 pr-4 rounded-full border border-[#E5E7EB] bg-white text-[13px] text-[#111827] shadow-2xs hover:border-[#D1D5DB] focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20 focus:outline-none transition-all"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#2563EB] focus:outline-none"
className="h-9 px-3.5 rounded-full border border-[#E5E7EB] bg-white text-[13px] text-[#1F2937] shadow-2xs hover:border-[#D1D5DB] focus:border-[#2563EB] focus:outline-none transition-all cursor-pointer"
>
<option value="all">All Statuses</option>
<option value="all">All</option>
<option value="active">Hiring</option>
<option value="draft">Draft</option>
<option value="paused">Paused</option>
@@ -65,7 +65,7 @@ export default function Positions() {
{isAdmin && <CertificationManager />}
<button
onClick={() => navigate('/positions/new')}
className="inline-flex items-center justify-center gap-1.5 bg-[#111827] hover:bg-[#1F2937] text-white text-[13px] font-medium px-4 h-10 rounded-lg transition-colors whitespace-nowrap"
className="inline-flex items-center justify-center gap-1.5 bg-[#111827] hover:bg-[#1F2937] text-white text-[13px] font-medium px-4 h-9 rounded-full shadow-xs transition-colors whitespace-nowrap"
>
<Plus className="w-4 h-4" /> New Position
</button>

View File

@@ -10,6 +10,7 @@ import {
import { SectionTitle } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import { completedCourseIds, skillStates } from '@/lib/skillGraph';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* KROW Forge — workforce skill verification, from the managing side.
@@ -88,7 +89,7 @@ export default function University() {
const statuses = useMemo(() => {
const found = new Set(courses.map(statusOf));
return [
{ value: 'all', label: 'All statuses' },
{ value: 'all', label: 'All' },
...STATUS_ORDER.filter((s) => found.has(s)).map((s) => ({ value: s, label: s })),
];
}, [courses]);
@@ -204,6 +205,8 @@ export default function University() {
<div className="space-y-7 pb-12">
<ForgeHeader onCreate={openCreate} onBrowse={scrollToLibrary} />
<SkillSurface page="krow-forge" placement="after-header" />
<ForgeProgress figures={figures} loading={isLoading} />
{/* Skill progression — the primary experience. The library below is the
@@ -302,6 +305,8 @@ export default function University() {
onCreate={(data) => createCourse.mutateAsync(data)}
onUpdate={(id, data) => updateCourse.mutateAsync({ id, data })}
/>
<SkillSurface page="krow-forge" placement="before-footer" />
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
import { useUserActivity } from '@/lib/krowHooks';
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Admin Activity — an audit centre.
@@ -144,6 +145,7 @@ export default function AdminActivity() {
title="Activity"
subtitle="Monitor workforce operations, user actions and system events."
>
<SkillSurface page="activity" placement="after-header" />
<MetricStrip
columns={4}
loading={isLoading}
@@ -293,6 +295,7 @@ export default function AdminActivity() {
]}
/>
</section>
<SkillSurface page="activity" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -1,6 +1,364 @@
import React from 'react';
import AdminHiredHistory from './HiredHistory';
import React, { useMemo } from 'react';
import {
Activity, Award, Clock, Gauge, Lightbulb, Target, TrendingUp, TriangleAlert,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge, MetricStrip, Surface } from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
import { HiringFlow } from '@/components/charts/HiringFlow';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { useSize } from '@/hooks/use-size';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import {
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
hiresWithFill, summarise,
} from '@/lib/hiringRecords';
/**
* Admin Analytics — how hiring is performing.
*
* This page and Hired History used to be the same component rendered twice with
* a different title, which is why they read as duplicates: identical sections,
* identical tabs, identical figures. They are not the same question.
*
* **Analytics asks "how is our hiring performing?"** — it is metrics, trends,
* comparison and what to do about them. Every section here is an aggregate: a
* rate, a distribution, a shape over time. No individual hire is named anywhere
* on this page, because a name is a record and records are Hired History's job.
*
* It is also structurally different, not just differently coloured. Analytics is
* one scrolling dashboard — the seven readings are meant to be taken together,
* and a comparison you have to remember across a tab switch is not a comparison.
* Hired History is a filtered record you search, so it keeps its controls and its
* table. Same design system, same blue, same cards; different jobs.
*
* Both read `lib/hiringRecords.js`, so a total here and the same total there
* cannot disagree.
*/
const INSIGHT_TONE = {
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
warning: { ring: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20', dot: 'text-amber-600 dark:text-amber-400', Icon: TriangleAlert },
risk: { ring: 'border-red-500/30 bg-red-50/40 dark:bg-red-950/20', dot: 'text-red-600 dark:text-red-400', Icon: TriangleAlert },
info: { ring: 'border-border bg-surface', dot: 'text-krow-blue', Icon: Lightbulb },
};
/** One finding, with the evidence underneath it. */
function Insight({ item }) {
const tone = INSIGHT_TONE[item.tone] || INSIGHT_TONE.info;
const { Icon } = tone;
return (
<li className={cn('flex items-start gap-2.5 rounded-xl border p-3.5', tone.ring)}>
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', tone.dot)} aria-hidden="true" />
<div className="min-w-0">
<p className="font-heading text-body-sm font-semibold text-ink-1">{item.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.body}</p>
</div>
</li>
);
}
/**
* The funnel, drawn once its container can be measured.
*
* `HiringFlow` renders an MUI bar chart with no explicit width, which means the
* chart measures its parent on mount. On this page that first measure lands
* before the layout has resolved a width — the shell's `main` is a `flex-1`
* column beside the Owliver panel — and the chart warns that it has nothing to
* size itself against. Gating on the measured width means the chart mounts once,
* already knowing how wide it is, instead of mounting into nothing and
* recovering.
*
* The reserved height keeps the section from collapsing and reflowing the page
* on the frame between measure and draw.
*/
function MeasuredFunnel({ funnel }) {
const ref = React.useRef(null);
const size = useSize(ref);
return (
<div ref={ref} className="w-full">
{size?.width ? (
<HiringFlow
stages={funnel.stages}
transitions={funnel.transitions}
weakestKey={funnel.weakestKey}
/>
) : (
<div className="h-[280px] rounded-xl border border-border bg-surface" aria-hidden="true" />
)}
</div>
);
}
/** A ranked comparison row — used for both fastest and slowest to fill. */
function VelocityList({ items, median, tone }) {
if (!items.length) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
No role has enough dated hires to measure velocity yet.
</p>
);
}
return (
<ul className="space-y-2">
{items.map((role) => {
const delta = median ? role.avgDays - median : 0;
return (
<li key={role.role} className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">{role.role}</p>
<p className="text-[10px] text-ink-4">
{role.count} hire{role.count === 1 ? '' : 's'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-heading text-body-sm font-bold tabular-nums text-ink-1">
{role.avgDays}d
</span>
{median > 0 && delta !== 0 && (
<Badge variant={tone === 'fast' ? 'success' : 'warning'} size="sm" className="tabular-nums">
{delta > 0 ? '+' : ''}{delta}d
</Badge>
)}
</div>
</li>
);
})}
</ul>
);
}
export default function AdminAnalytics() {
return <AdminHiredHistory title="Analytics" />;
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const hires = useMemo(
() => hiresWithFill({ staff, applications, postings }),
[staff, applications, postings]
);
const summary = useMemo(() => summarise(hires), [hires]);
const funnel = useMemo(() => buildFunnel(applications), [applications]);
const trend = useMemo(() => buildTrend(hires), [hires]);
const departments = useMemo(() => byDepartment(hires), [hires]);
const positions = useMemo(() => byPosition(hires), [hires]);
const efficiency = useMemo(() => buildEfficiency(hires), [hires]);
const insights = useMemo(
() => buildInsights({ hires, departments, positions, funnel, efficiency }),
[hires, departments, positions, funnel, efficiency]
);
return (
<AdminPage
title="Analytics"
subtitle="How hiring is performing — rates, trends, comparison and what to act on."
>
<SkillSurface page="analytics" placement="after-header" />
{/* 1. Hiring performance — the four figures the rest of the page explains. */}
<section aria-labelledby="performance" className="space-y-3">
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total hires', value: summary.total, icon: Award, tone: 'brand' },
{
label: 'Avg time-to-hire',
value: summary.speed ? `${summary.speed}d` : '—',
icon: Clock,
sub: efficiency.median ? `${efficiency.median}d median` : undefined,
},
{
label: 'Quality of hire',
value: summary.quality || '—',
icon: TrendingUp,
tone: summary.quality >= 80 ? 'success' : 'default',
sub: 'avg AI score',
},
{
label: 'Conversion rate',
value: `${funnel.conversion}%`,
icon: Target,
sub: `${funnel.stages[4].count} of ${funnel.stages[0].count} applicants`,
},
]}
/>
</section>
{/* 2. The funnel — where candidates are lost, which is the finding this
page exists to surface. */}
<section aria-labelledby="funnel" className="space-y-3">
<SectionTitle
id="funnel"
title="Hiring funnel"
meta="Applied → Screened → Shortlisted → Interview → Hired"
/>
{funnel.stages[0].count ? (
<MeasuredFunnel funnel={funnel} />
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
No applications on file yet. The funnel appears once the first candidate applies.
</p>
</Surface>
)}
</section>
{/* 3. Trend over time. */}
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<HiringTrendChart
points={trend}
emptyState={(
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
</p>
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
{hires.length
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p>
</div>
)}
/>
</section>
{/* 4. Department comparison. */}
<section aria-labelledby="departments" className="space-y-3">
<SectionTitle
id="departments"
title="Department performance"
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
/>
<DepartmentPerformance items={departments} />
</section>
{/* 5. Position comparison — the same question one level down. */}
<section aria-labelledby="positions" className="space-y-3">
<SectionTitle
id="positions"
title="Position performance"
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} filled`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4',
i ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{positions.map((r) => (
<tr key={r.role} className="border-b border-border/60 last:border-0 transition-colors hover:bg-surface-subtle/80">
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-3.5 py-2.5 text-right font-semibold tabular-nums text-ink-2">{r.count}</td>
<td className="px-3.5 py-2.5 text-right font-bold tabular-nums text-blue-600 dark:text-blue-400">{r.avgScore || '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-3.5 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* 6. Efficiency — velocity against this workspace's own median, so the
comparison is one an operator can check. */}
<section aria-labelledby="efficiency" className="space-y-3">
<SectionTitle
id="efficiency"
title="Hiring efficiency"
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-krow-blue">
<Gauge className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
</div>
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
{efficiency.median ? `${efficiency.median}d` : '—'}
<span className="ml-1.5 text-caption font-normal text-ink-3">median</span>
</p>
<p className="mt-1 text-caption leading-relaxed text-ink-3">
{efficiency.within48h
? `${efficiency.within48h}% of roles close within 48 hours.`
: 'Velocity appears once hires carry an application date.'}
</p>
</Surface>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<Activity className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
</div>
</Surface>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<Clock className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
</div>
</Surface>
</div>
</section>
{/* 7. What the numbers mean. Only findings the data actually supports —
an insight list that is always the same length is decoration. */}
<section aria-labelledby="insights" className="space-y-3">
<SectionTitle
id="insights"
title="AI hiring insights"
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
/>
{insights.length ? (
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{insights.map((item) => <Insight key={item.title} item={item} />)}
</ul>
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
There is not enough hiring on file to draw a finding from yet. Insights appear as
applications, hires and reviews accumulate.
</p>
</Surface>
)}
</section>
<SkillSurface page="analytics" placement="before-footer" />
</AdminPage>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import { toast } from 'react-hot-toast';
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import { SkillSurface } from '@/components/skills/SkillSurface';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
@@ -107,6 +108,8 @@ export default function AdminCandidates() {
title="Candidates"
subtitle="Review, compare and manage the candidate pipeline."
>
<SkillSurface page="candidates" placement="after-header" />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
filters={
@@ -165,6 +168,8 @@ export default function AdminCandidates() {
</div>
)}
<SkillSurface page="candidates" placement="before-footer" />
{/* Modals */}
{interviewApp && (
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />

View File

@@ -11,6 +11,7 @@ import {
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Admin Candidates Analysis — the analytical counterpart to Candidates.
@@ -165,6 +166,7 @@ export default function AdminCandidatesAnalysis() {
title="Candidates Analysis"
subtitle="Talent supply, quality distribution and pipeline risk."
>
<SkillSurface page="candidates-analysis" placement="after-header" />
<MetricStrip
columns={5}
items={[
@@ -188,6 +190,7 @@ export default function AdminCandidatesAnalysis() {
</PieChart>
</ResponsiveContainer>
<div className="space-y-1.5">
{bands.map((b) => (
<div key={b.name} className="flex items-center gap-2 text-caption">
<span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: b.color }} aria-hidden="true" />
@@ -413,6 +416,7 @@ export default function AdminCandidatesAnalysis() {
Full analytics <ArrowRight aria-hidden="true" />
</Button>
</section>
<SkillSurface page="candidates-analysis" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -17,6 +17,7 @@ import {
} from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage } from '@/pages/admin/_shell';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Control Center — the KROW Admin command centre.
@@ -180,6 +181,7 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
return (
<div className="space-y-4">
{/* 5 Stage Funnel Cards */}
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-5">
{funnel.map((stage, i) => {
@@ -584,6 +586,7 @@ export default function ControlCenter() {
</Button>
)}
>
<SkillSurface page="control-center" placement="after-header" />
{/* 1 ── Operations snapshot */}
<Snapshot metrics={metrics} loading={isLoading} />
@@ -973,6 +976,7 @@ export default function ControlCenter() {
</div>
</div>
</Band>
<SkillSurface page="control-center" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -1,459 +1,293 @@
import React, { useMemo, useState } from 'react';
import { ArrowRight, Award, Briefcase, Building2, CheckCircle2, ChevronRight, Clock, ShieldCheck, Sparkles, Star, TrendingUp, Users, Zap } from 'lucide-react';
import { Tab, TabList } from '@astryxdesign/core';
import { Award, Building2, CalendarDays, Clock, Mail, Phone, UserCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, Badge, DataTable, MetricStrip, StatusBadge, Surface } from '@/components/ds';
import {
Avatar, Badge, Button, DataTable, Drawer, EmptyState, FilterBar, MetricStrip, StatusBadge,
Surface, Timeline,
} from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { hiresWithFill, summarise } from '@/lib/hiringRecords';
/**
* Admin Hired History — hiring outcomes over time.
* Admin Hired History — the record of who was hired.
*
* Reporting rather than monitoring: the question here is "did our hiring work",
* so the page leads with trend and quality and keeps the roster underneath,
* instead of repeating the Control Center's live counts.
* This page and Analytics used to be one component rendered twice, which is why
* they read as duplicates. They answer different questions, and this is the one
* about *people*: **who did we hire, for which position, when, and what
* happened since.**
*
* The five sections are views of one workspace rather than a document to scroll:
* every one is a full-size analysis, and stacking them made the page four
* screens tall to reach the last. An Astryx TabList switches between them; the
* metric strip stays above the tabs because it is the summary of all five, not
* one of them. Nothing about the sections themselves changed — each is the same
* component, reading the same derived data, rendered one at a time.
* So it is built as a record rather than as a dashboard. There is no funnel, no
* trend line and no departmental comparison here — those are aggregate readings
* and they live on Analytics. What is here instead is what a record needs and a
* dashboard cannot give you: a search across every hire, filters for the fields
* you would search by, a chronology, and a full detail view for one person.
*
* The figures at the top are a count of what the filters currently select, not
* a performance summary — they answer "how much of the record am I looking at".
* Both pages read `lib/hiringRecords.js`, so the totals cannot disagree.
*/
/** Section views. `overview` is the landing view. */
const VIEWS = [
{ value: 'overview', label: 'Overview' },
{ value: 'departments', label: 'Department Breakdown' },
{ value: 'outcomes', label: 'Position Outcomes' },
{ value: 'comparison', label: 'Historical Comparison' },
{ value: 'recent', label: 'Recent Hires' },
/** Date-range windows, expressed as days back from today. */
const RANGES = [
{ value: 'all', label: 'Any time', days: null },
{ value: '7', label: 'Last 7 days', days: 7 },
{ value: '30', label: 'Last 30 days', days: 30 },
{ value: '90', label: 'Last 90 days', days: 90 },
];
export default function AdminHiredHistory({ title = 'Hired History' }) {
const [view, setView] = useState('overview');
const DAY = 24 * 60 * 60 * 1000;
const formatDate = (value) => (value
? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: '—');
/** One label/value row in the detail panel. */
function Fact({ label, children }) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-border px-4 py-2.5 last:border-0">
<span className="shrink-0 text-caption text-ink-4">{label}</span>
<span className="min-w-0 text-right text-body-sm font-medium text-ink-1">{children ?? '—'}</span>
</div>
);
}
/** The full record for one hire — the page's own detail view. */
function HireDetail({ hire, onClose }) {
if (!hire) return null;
return (
<Drawer
open={Boolean(hire)}
onOpenChange={(next) => { if (!next) onClose(); }}
title={hire.name}
description={[hire.role, hire.company].filter((v) => v && v !== '—').join(' · ') || 'Hired'}
icon={UserCheck}
size="default"
>
<div className="space-y-5 px-5 py-5 sm:px-6">
<div className="flex items-center gap-3">
<Avatar name={hire.name} size="lg" />
<div className="min-w-0">
<p className="truncate font-heading text-body font-semibold text-ink-1">{hire.name}</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<StatusBadge status={hire.status || 'hired'} size="sm" />
{hire.profile_tier && <Badge variant="neutral" size="sm">{hire.profile_tier}</Badge>}
</div>
</div>
</div>
<section>
<SectionTitle title="The hire" />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="mt-2 border border-border">
<Fact label="Position">{hire.role}</Fact>
<Fact label="Client">{hire.company}</Fact>
<Fact label="Department">{hire.department}</Fact>
<Fact label="Hired">{formatDate(hire.hire_date)}</Fact>
<Fact label="Time to hire">{hire.timeToHire ? `${hire.timeToHire} days` : null}</Fact>
<Fact label="AI score">{hire.score}</Fact>
<Fact label="Client review">
{hire.client_rating ? `${hire.client_rating} / 5` : 'Not reviewed yet'}
</Fact>
</Surface>
</section>
{(hire.email || hire.phone) && (
<section>
<SectionTitle title="Contact" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="mt-2 space-y-2 border border-border">
{hire.email && (
<a
href={`mailto:${hire.email}`}
className="flex items-center gap-2 text-body-sm text-ink-2 transition-colors hover:text-krow-blue"
>
<Mail className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{hire.email}</span>
</a>
)}
{hire.phone && (
<a
href={`tel:${hire.phone}`}
className="flex items-center gap-2 text-body-sm text-ink-2 transition-colors hover:text-krow-blue"
>
<Phone className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{hire.phone}</span>
</a>
)}
</Surface>
</section>
)}
{/* Per-record extension point, on the same terms as every other
surface: a definition names `hired-history`, this renders it. */}
<SkillSurface page="hired-history" placement="before-footer" context={{ candidate: hire }} />
</div>
</Drawer>
);
}
export default function AdminHiredHistory() {
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const DEFAULT_STAFF_HIRES = useMemo(() => [
{ id: 's1', name: 'Marco Rivera', email: 'marco.rivera@email.com', role: 'Experienced Bartender – Corporate Events', department: 'Bartender', profile_tier: 'skilled', score: 92, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's2', name: 'Chef Antoine Dubois', email: 'antoine.dubois@email.com', role: 'Executive Chef – Catering', department: 'Chef', profile_tier: 'skilled', score: 97, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's3', name: 'Marcus Williams', email: 'marcus.w@email.com', role: 'Event Security Officer', department: 'Security', profile_tier: 'skilled', score: 94, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
], []);
const [search, setSearch] = useState('');
const [filters, setFilters] = useState({ position: 'all', department: 'all', range: 'all' });
const [selected, setSelected] = useState(null);
const hires = useMemo(() => {
const liveList = staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
: null;
return {
...s,
department: posting?.role_category || s.department || '—',
timeToHire: days || s.timeToHire || 1,
score: s.ai_score || app?.ai_score || s.score || 90,
profile_tier: s.profile_tier || 'skilled',
};
});
const hires = useMemo(
() => hiresWithFill({ staff, applications, postings }),
[staff, applications, postings]
);
const emails = new Set(liveList.map((h) => (h.email || h.name).toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_STAFF_HIRES) {
if (!emails.has((def.email || def.name).toLowerCase())) {
result.push(def);
emails.add((def.email || def.name).toLowerCase());
}
}
return result;
}, [staff, applications, postings, DEFAULT_STAFF_HIRES]);
/* Filter options come from the record itself, so a department with no hires
is never offered as a filter that returns nothing. */
const positionOptions = useMemo(
() => [...new Set(hires.map((h) => h.role).filter((r) => r && r !== '—'))].sort(),
[hires]
);
const departmentOptions = useMemo(
() => [...new Set(hires.map((h) => h.department).filter((d) => d && d !== '—'))].sort(),
[hires]
);
const avg = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
const quality = avg(hires.map((h) => h.score).filter(Boolean));
const speed = avg(hires.map((h) => h.timeToHire).filter(Boolean));
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const range = RANGES.find((r) => r.value === filters.range);
const cutoff = range?.days ? Date.now() - range.days * DAY : null;
/* Cumulative hires by month — a trend needs a baseline, not a single bar. */
const trend = useMemo(() => {
const byMonth = new Map();
hires.forEach((h) => {
const d = new Date(h.hire_date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
byMonth.set(key, (byMonth.get(key) || 0) + 1);
});
let running = 0;
return [...byMonth.entries()].sort().map(([key, count]) => {
running += count;
const [y, m] = key.split('-');
return {
label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
hires: count,
cumulative: running,
};
});
}, [hires]);
return hires
.filter((h) => {
if (q && ![h.name, h.role, h.company, h.department, h.email]
.some((v) => String(v || '').toLowerCase().includes(q))) return false;
if (filters.position !== 'all' && h.role !== filters.position) return false;
if (filters.department !== 'all' && h.department !== filters.department) return false;
if (cutoff && new Date(h.hire_date || 0).getTime() < cutoff) return false;
return true;
})
.sort((a, b) => new Date(b.hire_date || 0) - new Date(a.hire_date || 0));
}, [hires, search, filters]);
const DEFAULT_DEPARTMENTS = useMemo(() => [
{ name: 'Chef', department: 'Chef', count: 1, avgScore: 97, avgTimeToHire: 1, roles: ['Executive Chef', 'Sous Chef'], hiresList: [{ id: 'h1', name: 'Marcus Vance', role: 'Executive Chef', score: 97 }] },
{ name: 'Security', department: 'Security', count: 1, avgScore: 94, avgTimeToHire: 1, roles: ['Lead Security Guard'], hiresList: [{ id: 'h2', name: 'Elena Rostova', role: 'Lead Security Guard', score: 94 }] },
{ name: 'Bartender', department: 'Bartender', count: 1, avgScore: 92, avgTimeToHire: 1, roles: ['Senior Mixologist'], hiresList: [{ id: 'h3', name: 'David Kim', role: 'Senior Mixologist', score: 92 }] },
{ name: 'Front Desk', department: 'Front Desk', count: 2, avgScore: 89, avgTimeToHire: 2, roles: ['Guest Relations Lead', 'Concierge'], hiresList: [{ id: 'h4', name: 'Sophia Chen', role: 'Guest Relations Lead', score: 90 }, { id: 'h5', name: 'Lucas Wright', role: 'Concierge', score: 88 }] },
{ name: 'Event Manager', department: 'Event Manager', count: 1, avgScore: 91, avgTimeToHire: 3, roles: ['Event Coordinator'], hiresList: [{ id: 'h6', name: 'Oliver Bennett', role: 'Event Coordinator', score: 91 }] },
{ name: 'Housekeeping', department: 'Housekeeping', count: 3, avgScore: 86, avgTimeToHire: 2, roles: ['Operations Supervisor', 'Sanitation Lead'], hiresList: [{ id: 'h7', name: 'Aaliyah Patel', role: 'Operations Supervisor', score: 87 }] },
], []);
const summary = useMemo(() => summarise(filtered), [filtered]);
const isFiltered = Boolean(search) || Object.values(filters).some((v) => v !== 'all');
const byDepartment = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const deptName = h.department && h.department !== '—' ? h.department : 'General';
const e = map.get(deptName) || {
department: deptName,
count: 0,
scores: [],
times: [],
rolesSet: new Set(),
hiresList: [],
};
e.count += 1;
if (h.score) e.scores.push(h.score);
if (h.timeToHire) e.times.push(h.timeToHire);
if (h.role) e.rolesSet.add(h.role);
e.hiresList.push(h);
map.set(deptName, e);
});
/* Recent first, and only what a chronology can actually show — a timeline of
forty rows is a table with worse density. */
const recent = useMemo(() => filtered.filter((h) => h.hire_date).slice(0, 6), [filtered]);
const liveList = [...map.values()].map((e) => ({
name: e.department,
department: e.department,
count: e.count,
scores: e.scores,
avgScore: avg(e.scores),
avgTimeToHire: avg(e.times),
roles: Array.from(e.rolesSet),
hiresList: e.hiresList,
}));
const names = new Set(liveList.map((d) => d.name.toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_DEPARTMENTS) {
if (result.length >= 6) break;
if (!names.has(def.name.toLowerCase())) {
result.push(def);
names.add(def.name.toLowerCase());
}
}
return result.sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
}, [hires, DEFAULT_DEPARTMENTS]);
/**
* Outcomes per position: how many were hired, how fast, and how well they
* scored. This is the retrospective question Positions cannot answer, because
* Positions is about roles that are still open.
*/
const DEFAULT_POSITIONS = useMemo(() => [
{ role: 'Executive Chef', count: 1, avgScore: 97, avgDays: 1, rated: 1, avgRating: 5.0 },
{ role: 'Lead Security Guard', count: 1, avgScore: 94, avgDays: 1, rated: 1, avgRating: 4.9 },
{ role: 'Senior Mixologist', count: 1, avgScore: 92, avgDays: 1, rated: 1, avgRating: 4.8 },
{ role: 'Guest Relations Lead', count: 2, avgScore: 89, avgDays: 2, rated: 2, avgRating: 4.7 },
{ role: 'Event Coordinator', count: 1, avgScore: 91, avgDays: 3, rated: 1, avgRating: 4.9 },
{ role: 'Operations Supervisor', count: 3, avgScore: 86, avgDays: 2, rated: 3, avgRating: 4.6 },
], []);
const byPosition = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const key = h.role || 'Unspecified';
const e = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
e.count += 1;
if (h.score) e.scores.push(h.score);
if (h.timeToHire) e.days.push(h.timeToHire);
if (h.client_rating) { e.rated += 1; e.ratings.push(h.client_rating); }
map.set(key, e);
});
const liveList = [...map.values()].map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((x, y) => x + y, 0) / e.ratings.length).toFixed(1))
: null,
}));
const roleNames = new Set(liveList.map((p) => p.role.toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_POSITIONS) {
if (result.length >= 6) break;
if (!roleNames.has(def.role.toLowerCase())) {
result.push(def);
roleNames.add(def.role.toLowerCase());
}
}
return result.sort((a, b) => b.count - a.count);
}, [hires, DEFAULT_POSITIONS]);
/**
* Historical comparison: this half of the dataset against the previous half.
*
* Split at the median hire date rather than by calendar month, because a demo
* dataset spanning a few weeks has no month boundary to compare across — and a
* comparison that silently returns zero is worse than one that adapts.
*/
const comparison = useMemo(() => {
const dated = hires
.filter((h) => h.hire_date)
.sort((a, b) => new Date(a.hire_date) - new Date(b.hire_date));
if (dated.length < 2) return null;
const midpoint = Math.floor(dated.length / 2);
const earlier = dated.slice(0, midpoint);
const later = dated.slice(midpoint);
const summarise = (set) => ({
hires: set.length,
quality: avg(set.map((h) => h.score).filter(Boolean)),
speed: avg(set.map((h) => h.timeToHire).filter(Boolean)),
});
const from = summarise(earlier);
const to = summarise(later);
const label = (set) => new Date(set[0].hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return {
fromLabel: label(earlier),
toLabel: label(later),
rows: [
{ metric: 'Hires closed', from: from.hires, to: to.hires, betterIsUp: true },
{ metric: 'Quality of hire', from: from.quality, to: to.quality, betterIsUp: true },
{ metric: 'Days to hire', from: from.speed, to: to.speed, betterIsUp: false },
],
};
}, [hires]);
const clearFilters = () => {
setSearch('');
setFilters({ position: 'all', department: 'all', range: 'all' });
};
return (
<AdminPage
title={title}
subtitle="Hiring outcomes, velocity and quality over time."
tabs={(
<div className="krow-astryx krow-astryx-scroll">
<TabList value={view} onChange={setView} size="sm" hasDivider aria-label="Hired History sections">
{VIEWS.map((v) => <Tab key={v.value} value={v.value} label={v.label} />)}
</TabList>
</div>
)}
title="Hired History"
subtitle="Who was hired, for which position, and what has happened since."
meta={`${hires.length} on record`}
>
{view === 'overview' && (
<>
<SkillSurface page="hired-history" placement="after-header" />
{/* 1. Search and filters — the page is a record, so finding one is the
first thing it has to do well. */}
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search by name, position, client or department…"
filters={[
{ key: 'position', label: 'Position', type: 'select', options: [{ value: 'all', label: 'All positions' }, ...positionOptions.map((p) => ({ value: p, label: p }))] },
{ key: 'department', label: 'Department', type: 'select', options: [{ value: 'all', label: 'All departments' }, ...departmentOptions.map((d) => ({ value: d, label: d }))] },
{ key: 'range', label: 'Hired', type: 'select', options: RANGES.map((r) => ({ value: r.value, label: r.label })) },
]}
values={filters}
onChange={setFilters}
/>
{/* 2. What the current selection contains. A count of the record, not a
performance verdict — that reading is Analytics'. */}
<MetricStrip
columns={4}
columns={3}
loading={isLoading}
items={[
{ label: 'Total hires', value: hires.length, icon: Award, tone: 'brand' },
{ label: 'Avg time-to-hire', value: speed ? `${speed}d` : '—', icon: Clock, delta: -38, invertTrend: true, deltaLabel: 'vs 5d norm' },
{ label: 'Quality of hire', value: quality || '—', icon: TrendingUp, tone: quality >= 80 ? 'success' : 'default', sub: 'avg AI score' },
{ label: 'Still active', value: hires.filter((h) => h.status !== 'inactive').length, icon: Users, sub: `${hires.filter((h) => h.status === 'onboarding').length} onboarding` },
{
label: isFiltered ? 'Hires matching' : 'Total hires',
value: summary.total,
icon: Award,
tone: 'brand',
sub: isFiltered ? `of ${hires.length} on record` : undefined,
},
{
label: 'Most recent hire',
value: recent[0] ? formatDate(recent[0].hire_date) : '—',
icon: CalendarDays,
sub: recent[0]?.name,
},
{
label: 'Avg time-to-hire',
value: summary.speed ? `${summary.speed}d` : '—',
icon: Clock,
sub: `${summary.active} still active`,
},
]}
/>
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<HiringTrendChart
points={trend}
emptyState={(
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
</p>
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
{hires.length
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p>
</div>
)}
/>
</section>
{/* Executive Highlights & Quick Insights Section (Fills empty vertical space with valuable analytics) */}
<div className="mt-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-heading text-body-lg font-bold text-ink-1 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-600" />
Executive Hiring Insights
</h3>
<p className="text-caption text-ink-3">Key performance highlights derived from active hiring data</p>
</div>
</div>
{/* 3 Executive Insight Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* Card 1: AI Score & Quality */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-blue-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">Quality Index</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
<Sparkles className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
{quality || 92} <span className="text-caption font-normal text-ink-3">/ 100 AI Score</span>
</p>
<p className="mt-1 text-caption text-ink-3">
Top Dept: <span className="font-semibold text-ink-1">{byDepartment[0]?.department || 'Chef'}</span> ({byDepartment[0]?.avgScore || 97} Avg)
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<span>100% Top Verified Profiles</span>
</div>
</div>
{/* Card 2: Velocity & Fill Speed */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-emerald-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Time-to-Hire Efficiency</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400">
<Zap className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
{speed || 2}d <span className="text-caption font-normal text-emerald-600 font-semibold">(-38% vs norm)</span>
</p>
<p className="mt-1 text-caption text-ink-3">
Industry norm: 5 days average
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-blue-50 dark:bg-blue-950/40 px-2.5 py-1 text-[11px] font-medium text-blue-700 dark:text-blue-400">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>80% roles filled within 48h</span>
</div>
</div>
{/* Card 3: Retention & Client Satisfaction */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-purple-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-600 dark:text-purple-400">Workforce Health</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-50 text-purple-600 dark:bg-purple-950 dark:text-purple-400">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
100% <span className="text-caption font-normal text-ink-3">Active Retention</span>
</p>
<p className="mt-1 text-caption text-ink-3">
{hires.filter((h) => h.status === 'onboarding').length || 3} hires currently in onboarding
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-amber-50 dark:bg-amber-950/40 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-400">
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-500" />
<span>4.9 / 5.0 Avg Rating</span>
</div>
</div>
</div>
{/* 2-Column Summary Cards: Top Departments & Position Breakdown */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Top Departments Summary */}
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-blue-600" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Top Performing Departments</h4>
</div>
<button
type="button"
onClick={() => setView('departments')}
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
>
<span>View all</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 space-y-2.5">
{byDepartment.slice(0, 3).map((dept, i) => (
<div key={dept.department} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
<div className="flex items-center gap-2.5">
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-950 text-[10px] font-bold text-blue-700 dark:text-blue-400">
#{i + 1}
</span>
<div>
<p className="font-heading text-body-sm font-semibold text-ink-1">{dept.department}</p>
<p className="text-[10px] text-ink-3">{dept.count} {dept.count === 1 ? 'hire' : 'hires'} • {dept.avgDays || 2}d avg velocity</p>
</div>
</div>
<div className="text-right">
<span className="font-heading text-body-sm font-bold text-blue-600 dark:text-blue-400">{dept.avgScore || 90}</span>
<span className="block text-[9px] text-ink-4">AI Score</span>
</div>
</div>
))}
</div>
</div>
{/* Position Outcomes Summary */}
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2">
<Briefcase className="h-4 w-4 text-emerald-600" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Position Highlights</h4>
</div>
<button
type="button"
onClick={() => setView('outcomes')}
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
>
<span>View all</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 space-y-2.5">
{byPosition.slice(0, 3).map((pos) => (
<div key={pos.role} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
<div className="min-w-0 flex-1 pr-3">
<p className="truncate font-heading text-body-sm font-semibold text-ink-1">{pos.role}</p>
<p className="text-[10px] text-ink-3">{pos.count} filled • {pos.avgDays ? `${pos.avgDays}d fill time` : '1d fill time'}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="success" size="sm" className="font-bold">{pos.avgRating || 5}/5 Rating</Badge>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</>
{/* 3. The chronology. */}
{recent.length > 0 && (
<section aria-labelledby="chronology" className="space-y-3">
<SectionTitle
id="chronology"
title="Recent hiring timeline"
meta={`Last ${recent.length} of ${filtered.length}`}
/>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<Timeline
items={recent.map((h, i) => ({
id: h.id,
title: h.name,
description: [h.role, h.company !== '—' ? h.company : null]
.filter(Boolean).join(' · '),
timestamp: formatDate(h.hire_date),
tone: i === 0 ? 'brand' : 'neutral',
current: i === 0,
icon: UserCheck,
}))}
/>
</Surface>
</section>
)}
{view === 'departments' && (
<section aria-labelledby="dept" className="space-y-3">
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
<DepartmentPerformance
items={byDepartment}
{/* 4. The records themselves — every field a hire carries, one row each. */}
<section aria-labelledby="records" className="space-y-3">
<SectionTitle
id="records"
title="Hired candidate records"
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
/>
</section>
)}
{view === 'recent' && (
<section aria-labelledby="roster" className="space-y-3">
<SectionTitle id="roster" title="Recent hires" meta={`${hires.length} people`} />
<DataTable
loading={isLoading}
rows={hires}
pageSize={10}
caption="Everyone hired, with tier and time-to-hire"
rows={filtered}
pageSize={12}
caption="Everyone hired, with position, client, score and time-to-hire"
onRowClick={setSelected}
isFiltered={isFiltered}
onClearFilters={clearFilters}
emptyState={(
<EmptyState
title={isFiltered ? 'No hires match these filters' : 'No hires recorded yet'}
description={
isFiltered
? 'Try a broader search, or clear the filters to see the whole record.'
: 'Hires appear here as positions close.'
}
action={isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
: undefined}
/>
)}
columns={[
{
key: 'name', header: 'Hire', sortable: true,
key: 'name', header: 'Candidate', sortable: true,
cell: (h) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={h.name} size="sm" />
@@ -464,235 +298,48 @@ export default function AdminHiredHistory({ title = 'Hired History' }) {
</div>
),
},
{ key: 'role', header: 'Role', sortable: true, hideBelow: 'md' },
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
{ key: 'profile_tier', header: 'Tier', cell: (h) => <StatusBadge status={h.profile_tier} size="sm" /> },
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
{
key: 'score', header: 'Score', align: 'right', sortable: true,
cell: (h) => (h.score ? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span> : '—'),
key: 'company', header: 'Company', sortable: true, hideBelow: 'lg',
cell: (h) => (h.company && h.company !== '—'
? (
<span className="inline-flex items-center gap-1.5 text-ink-2">
<Building2 className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<span className="truncate">{h.company}</span>
</span>
)
: '—'),
},
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
{
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
cell: (h) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">{formatDate(h.hire_date)}</span>
),
},
{
key: 'score', header: 'AI score', align: 'right', sortable: true,
cell: (h) => (h.score
? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span>
: '—'),
},
{
key: 'timeToHire', header: 'Time to hire', align: 'right', sortable: true, hideBelow: 'lg',
cell: (h) => (h.timeToHire ? `${h.timeToHire}d` : '—'),
},
{
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
cell: (h) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(h.hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
),
},
{
key: 'client_rating', header: 'Review', align: 'right', hideBelow: 'md',
cell: (h) => (h.client_rating
? <Badge variant="success" size="sm">{h.client_rating}/5</Badge>
: <Badge variant="warning" size="sm">Pending</Badge>),
key: 'status', header: 'Status', align: 'right',
cell: (h) => <StatusBadge status={h.status || 'hired'} size="sm" />,
},
]}
/>
<p className={cn('text-caption text-ink-4')}>
Select a row to open the full record.
</p>
</section>
)}
{/* Position outcomes and Historical comparison each own a view now, so the
two-column pairing they used to share is no longer needed: each has the
full width of the workspace to itself. */}
{view === 'outcomes' && (
<section aria-labelledby="outcomes" className="space-y-3">
<SectionTitle id="outcomes" title="Position outcomes" meta={`${byPosition.length} roles filled`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{byPosition.map((r) => (
<tr key={r.role} className="border-b border-border/60 last:border-0 hover:bg-surface-subtle/80 transition-colors">
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2 font-semibold">{r.count}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-blue-600 dark:text-blue-400 font-bold">{r.avgScore || '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-3.5 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
)}
{view === 'comparison' && (
comparison ? (
<section aria-labelledby="compare" className="space-y-5">
<SectionTitle
id="compare"
title="Historical comparison"
meta={`Period 1 (${comparison.fromLabel}) vs Period 2 (${comparison.toLabel})`}
/>
{/* 1. Period Cohort Highlights (2 Cards) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-4">Cohort 1 (Earlier Half)</span>
<span className="rounded-full bg-surface-subtle border border-border px-2 py-0.5 text-[10px] font-semibold text-ink-3">
{comparison.fromLabel}
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
<p className="font-heading text-body-lg font-bold text-blue-600 dark:text-blue-400 mt-0.5">90</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">2d</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-50/30 via-surface to-surface dark:from-emerald-950/20 p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Cohort 2 (Recent Half)</span>
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2 py-0.5 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
{comparison.toLabel} • +3pt Quality Gain
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">93</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">1d</p>
</div>
</div>
</div>
</div>
{/* 2. Expanded Benchmark Comparison Metrics Table */}
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="border-b border-border bg-surface-subtle px-4 py-2.5 flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-ink-4">Comparative Benchmark Metrics</span>
<span className="text-[10px] text-ink-4">Median benchmark split</span>
</div>
<ul className="divide-y divide-border">
{[
...comparison.rows,
{ metric: 'Top Tier Candidates Ratio', from: '75%', to: '100%', flat: false, improved: true, deltaText: '+25%' },
{ metric: 'Client Satisfaction Rating', from: '4.7', to: '4.9', flat: false, improved: true, deltaText: '+0.2' },
{ metric: 'AI Match Precision Index', from: '92%', to: '98%', flat: false, improved: true, deltaText: '+6%' },
].map((row) => {
const delta = row.to !== undefined && row.from !== undefined && typeof row.to === 'number' ? row.to - row.from : null;
const improved = row.improved !== undefined ? row.improved : (row.betterIsUp ? delta > 0 : delta < 0);
const flat = row.flat !== undefined ? row.flat : (delta === 0);
const deltaDisplay = row.deltaText || (flat ? 'level' : `${delta > 0 ? '+' : ''}${delta}`);
return (
<li key={row.metric} className="flex items-center justify-between gap-3 px-4 py-3.5 hover:bg-surface-subtle/40 transition-colors">
<div className="min-w-0 flex-1">
<span className="block text-body-sm font-semibold text-ink-1">{row.metric}</span>
<span className="text-[10px] text-ink-4">Period-over-period benchmark</span>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<span className="w-12 text-right font-heading text-body-sm font-semibold tabular-nums text-ink-4">
{row.from || '—'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span className="w-12 text-right font-heading text-body-sm font-bold tabular-nums text-ink-1">
{row.to || '—'}
</span>
<span
className={cn(
'inline-flex items-center justify-center rounded-full px-2.5 py-0.5 text-[10px] font-bold tabular-nums border min-w-[50px]',
flat
? 'bg-surface-subtle text-ink-4 border-border'
: improved
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-400'
: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-400'
)}
>
{deltaDisplay}
</span>
</div>
</li>
);
})}
</ul>
</Surface>
{/* 3. Executive Analysis Insights Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
<TrendingUp className="h-4 w-4" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Quality Progression</h4>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
Average AI Candidate Score increased from <span className="font-semibold text-ink-1">90 to 93</span> (+3 pts) between {comparison.fromLabel} and {comparison.toLabel}.
100% of candidates in the recent cohort reached Top Tier status with 0 drop-offs.
</p>
</div>
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<Zap className="h-4 w-4" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Velocity Optimization</h4>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
Average time-to-hire decreased from <span className="font-semibold text-ink-1">2 days to 1 day</span> per position, achieving a <span className="font-semibold text-emerald-600">50% speed improvement</span> while maintaining 100% candidate verification.
</p>
</div>
</div>
<p className="text-caption text-ink-4">
Dataset split at median hire date ({comparison.fromLabel} vs {comparison.toLabel}). Lower days indicates faster hiring velocity.
</p>
</section>
) : (
/* The comparison needs two dated hires to have two halves to compare.
With fewer, the section used to disappear from the page entirely —
acceptable when it sat between others, but a tab that opens onto
nothing reads as broken. */
<section aria-labelledby="compare" className="space-y-3">
<SectionTitle id="compare" title="Historical comparison" meta="Not enough history yet" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
A comparison needs two dated hires to split the record into an earlier and a
later half. It appears here once a second hire is on file.
</p>
</Surface>
</section>
)
)}
{/* 5. One person, in full. */}
<HireDetail hire={selected} onClose={() => setSelected(null)} />
</AdminPage>
);
}

View File

@@ -0,0 +1,640 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft, LayoutTemplate, Upload, X } from 'lucide-react';
import {
Badge, Button, Checkbox, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
import { customSkillSource, owliverSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import {
DATA_SOURCES, OWLIVER_CAPABILITIES, PERIODS, dataSourceFor, dataSourceLabel,
owliverCapabilityLabel, periodLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Add or edit an Owliver skill.
*
* The counterpart to the UI Skill Editor, and separate from it for one reason:
* the two are configuring different things. A UI skill answers "what does this
* page draw, where, from what"; an Owliver skill answers "what can I be asked
* for here, and what shape does the answer take". One form holding both fields
* asked the author to know which half applied to them, and the commonest way to
* get that wrong was to fill in both.
*
* What is *not* separate: the format, the parser, the validator, the registry,
* the persistence and the data resolver. This page composes the same Markdown
* and saves it through the same helper the other editor uses. It is a second
* door onto one system, never a second system.
*/
/** The fields this editor composes, before the Markdown is edited by hand. */
const emptyDraft = {
id: '',
name: '',
description: '',
pages: [],
triggers: [],
suggestions: [],
capabilities: [],
source: '',
periods: [],
};
/**
* A definition, read back into the fields.
*
* Editing starts from what the registry made of the file rather than from the
* Markdown text, so the form shows what is actually registered — including the
* capabilities whose reading was inherited from a `ui:` block rather than
* written out.
*/
function draftFromSource(source) {
try {
const skill = parseSkill(source, { custom: true });
const first = skill.owliver.capabilities[0];
const response = first ? skill.owliver.responses[first] : null;
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
triggers: skill.triggers,
suggestions: skill.owliver.suggestions.map((s) => s.label),
capabilities: skill.owliver.capabilities,
source: response?.source || '',
periods: response?.periods || [],
};
} catch {
return emptyDraft;
}
}
/** A list of short lines an author edits one at a time. */
function LineList({ items, onChange, placeholder, addLabel, ariaLabel }) {
const [entry, setEntry] = useState('');
const add = () => {
const value = entry.trim();
if (!value || items.includes(value)) {
setEntry('');
return;
}
onChange([...items, value]);
setEntry('');
};
return (
<div className="space-y-2">
{items.length > 0 && (
<ul className="flex flex-wrap gap-1.5" aria-label={ariaLabel}>
{items.map((item) => (
<li key={item}>
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-sunken/60 py-0.5 pl-2.5 pr-1 text-caption text-ink-2">
{item}
<button
type="button"
onClick={() => onChange(items.filter((i) => i !== item))}
aria-label={`Remove ${item}`}
className="grid h-4 w-4 place-items-center rounded-full text-ink-4 transition-colors hover:bg-surface-sunken hover:text-ink-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
</span>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Input
value={entry}
placeholder={placeholder}
onChange={(e) => setEntry(e.target.value)}
/* Enter adds the line rather than submitting anything: this list is
one field among several, and losing a half-typed trigger to a page
navigation would be its own bug. */
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
add();
}}
/>
<Button variant="outline" size="sm" shape="rounded" onClick={add}>{addLabel}</Button>
</div>
</div>
);
}
export default function OwliverSkillEditor() {
const navigate = useNavigate();
const { id: routeId } = useParams();
const editingId = routeId && routeId !== 'new' ? routeId : null;
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
const customSkills = preferences.customSkills || [];
/**
* The definition being worked on.
*
* Editing an account skill loads its stored Markdown; editing one shipped
* with KROW loads that file's Markdown as a starting point, and saving writes
* an account definition with the same id — the override the registry already
* understands, not a duplicate.
*/
const initial = useMemo(() => {
if (!editingId) return owliverSkillTemplate({});
const stored = customSkillSource(customSkills, editingId);
if (stored) return stored;
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
return shipped?.markdown || owliverSkillTemplate({});
/* Deliberately keyed on the skill alone: retyping must not be overwritten
by a preferences write elsewhere in the app. */
}, [editingId]);
const [source, setSource] = useState(initial);
const [draft, setDraft] = useState(() => (editingId ? draftFromSource(initial) : emptyDraft));
const [touched, setTouched] = useState(Boolean(editingId));
const [error, setError] = useState('');
const fileRef = React.useRef(null);
/* What the registry will make of what is in the editor right now, so the
preview cannot flatter the definition. */
const preview = useMemo(() => {
try {
return parseSkill(source, { custom: true });
} catch {
return null;
}
}, [source]);
const disabledSkills = preferences.disabledSkills || [];
const declaredActive = preview?.status !== 'inactive';
const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
/**
* Typing in the fields composes the Markdown until the Markdown itself is
* edited, after which it wins — it is the thing being saved, and silently
* regenerating over an author's own text would lose work they can see.
*/
const update = (patch) => {
const next = { ...draft, ...patch };
setDraft(next);
if (!touched) setSource(owliverSkillTemplate(next));
};
const setStatus = (nextActive) => {
const next = /^status:\s*\w+$/m.test(source)
? source.replace(/^status:\s*\w+$/m, `status: ${nextActive ? 'active' : 'inactive'}`)
: source.replace(/^---\n/, `---\nstatus: ${nextActive ? 'active' : 'inactive'}\n`);
setSource(next);
setTouched(true);
};
const readFile = (event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setSource(String(reader.result));
setTouched(true);
setError('');
};
reader.readAsText(file);
event.target.value = '';
};
const save = () => {
const problem = validateSkillSource(source);
if (problem) {
setError(problem);
return;
}
const { skill, next } = upsertCustomSkill(customSkills, source);
const nextDisabled = skill.status === 'inactive'
? [...new Set([...disabledSkills, skill.id])]
: disabledSkills.filter((id) => id !== skill.id);
updatePreferences.mutate({ customSkills: next, disabledSkills: nextDisabled });
toast.success(editingId ? `${skill.name} updated` : `${skill.name} added`);
navigate('/admin/workspace/skills?tab=owliver');
};
/* Only the sources the declared pages can actually supply context for are
worth offering — a source needing a candidate is not answerable from the
Analytics panel, and offering it would validate and then never resolve. */
const source_ = draft.source ? dataSourceFor(draft.source) : null;
const timeBased = Boolean(source_?.shapes?.includes('flow') || source_?.shapes?.includes('timeline'));
/* The other half of the same definition, when it has one. Read-only: this
editor does not own it, but hiding it would misrepresent the skill. */
const uiSections = Object.values(preview?.ui || {}).flatMap((page) => page.sections || []);
return (
<AdminPage
title={editingId ? 'Edit Owliver skill' : 'Add Owliver skill'}
subtitle="Define what Owliver can be asked for, and the pages it can be asked on."
actions={
<>
<Button
variant="outline"
size="sm"
shape="rounded"
onClick={() => navigate('/admin/workspace/skills?tab=owliver')}
>
<ChevronLeft aria-hidden="true" /> Skills
</Button>
<Button size="sm" shape="rounded" onClick={save} loading={updatePreferences.isPending}>
{editingId ? 'Save skill' : 'Add skill'}
</Button>
</>
}
>
<div className="grid w-full grid-cols-1 gap-6 lg:grid-cols-[1fr_360px] lg:items-start xl:grid-cols-[1fr_400px] 2xl:grid-cols-[1fr_440px]">
<div className="min-w-0 space-y-6">
{/* ── Identity and reach ─────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Owliver skill" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Skill name" required>
<Input
value={draft.name}
placeholder="Hiring Activity Assistant"
onChange={(e) => update({ name: e.target.value })}
/>
</Field>
<Field label="Skill ID" required hint="Lower-case letters, numbers and dashes.">
<Input
value={draft.id}
placeholder="hiring-activity-assistant"
onChange={(e) => update({ id: e.target.value })}
/>
</Field>
</div>
<Field label="Description" hint="Owliver reads this when deciding whether a question is for this skill.">
<Input
value={draft.description}
placeholder="Answer questions about recent hiring activity on a position."
onChange={(e) => update({ description: e.target.value })}
/>
</Field>
<Field
label="Pages / context"
required
hint="Where should Owliver know this skill? The panel on these pages offers it; no other panel does."
>
<div className="space-y-2.5">
<MultiSelect
options={PAGE_KEYS.map((k) => ({ value: k, label: surfaceFor(k)?.label || k }))}
value={draft.pages}
onChange={(pages) => update({ pages })}
placeholder="Select pages (e.g. positions, candidates...)"
maxChips={6}
/>
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="mr-1 text-[11px] font-medium text-ink-4">Available pages:</span>
{PAGE_KEYS.map((pageKey) => {
const selected = draft.pages.includes(pageKey);
return (
<button
key={pageKey}
type="button"
onClick={() => update({
pages: selected
? draft.pages.filter((p) => p !== pageKey)
: [...draft.pages, pageKey],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{surfaceFor(pageKey)?.label || pageKey}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</div>
</Field>
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-2">Active</p>
<p className="text-caption text-ink-3">
An inactive skill stays registered, but Owliver offers neither its suggestions nor its answers.
</p>
</div>
<Switch checked={active} onCheckedChange={setStatus} aria-label="Skill active" />
</div>
</Surface>
</section>
{/* ── What starts it ─────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Triggers" meta="What activates this skill" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<Field
label="Words and phrases"
hint="A question containing one of these is treated as being about this skill. `*` stands for anything in between."
>
<LineList
items={draft.triggers}
onChange={(triggers) => update({ triggers })}
placeholder="hiring activity"
addLabel="Add trigger"
ariaLabel="Triggers"
/>
</Field>
</Surface>
</section>
{/* ── What it offers ─────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Suggestions" meta="Shown as chips inside Owliver" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<Field
label="Suggested prompts"
hint="Offered on the pages above. Clicking one asks it, exactly as typing it would."
>
<LineList
items={draft.suggestions}
onChange={(suggestions) => update({ suggestions })}
placeholder="Show hiring activity"
addLabel="Add suggestion"
ariaLabel="Suggestions"
/>
</Field>
</Surface>
</section>
{/* ── What an answer looks like ──────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Capabilities" meta="The shapes an answer can take" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="grid gap-2.5 sm:grid-cols-2">
{OWLIVER_CAPABILITIES.map((capability) => {
const checked = draft.capabilities.includes(capability.id);
return (
<label
key={capability.id}
className={cn(
'flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 transition-colors',
checked ? 'border-krow-blue/30 bg-krow-blue-tint/50' : 'border-border bg-surface hover:bg-surface-subtle'
)}
>
<Checkbox
checked={checked}
onCheckedChange={() => update({
capabilities: checked
? draft.capabilities.filter((c) => c !== capability.id)
: [...draft.capabilities, capability.id],
})}
aria-label={capability.label}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="block text-body-sm font-medium text-ink-1">{capability.label}</span>
<span className="block text-caption leading-relaxed text-ink-3">{capability.summary}</span>
</span>
</label>
);
})}
</div>
</Surface>
</section>
{/* ── What it reads ──────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Data" meta="The same resolver the pages use" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Field
label="Source"
hint="A named reading of data KROW already holds. A UI skill naming the same source reads exactly the same records."
>
<Select value={draft.source || undefined} onValueChange={(value) => update({ source: value })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.label} — {s.summary}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{timeBased && (
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
const selected = draft.periods.includes(period.id);
return (
<button
key={period.id}
type="button"
onClick={() => update({
periods: selected
? draft.periods.filter((p) => p !== period.id)
: [...draft.periods, period.id],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{period.label}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</Field>
)}
</Surface>
</section>
{/* ── The artefact ───────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Definition" meta="Markdown" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Field
label="Skill definition"
hint="This is what gets saved. Edit it directly and it stops being regenerated from the fields above."
error={error}
>
<Textarea
value={source}
rows={20}
spellCheck={false}
className="rounded-lg border-border/80 bg-surface-sunken/40 p-3.5 font-mono text-[12.5px] leading-relaxed transition-all focus:border-krow-blue"
onChange={(e) => {
setSource(e.target.value);
setTouched(true);
if (error) setError('');
}}
/>
</Field>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/50 pt-3">
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept=".md,text/markdown"
onChange={readFile}
className="hidden"
/>
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
</div>
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
</div>
</Surface>
</section>
</div>
{/* ── What the registry will make of it ────────────────────────── */}
<aside className="space-y-3 lg:sticky lg:top-20">
<SectionTitle title="Preview" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
{preview ? (
<>
<div className="flex flex-wrap items-center gap-2">
<p className="text-body-sm font-semibold text-ink-1">{preview.name}</p>
<Badge variant={active ? 'success' : 'neutral'} size="sm">
{active ? 'Active' : 'Inactive'}
</Badge>
</div>
{preview.description && (
<p className="text-caption leading-relaxed text-ink-3">{preview.description}</p>
)}
<dl className="space-y-2 border-t border-border pt-3">
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Skill ID</dt>
<dd className="font-mono text-caption text-ink-2">{preview.id}</dd>
</div>
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Pages</dt>
<dd className="text-caption text-ink-2">
{preview.pages.length
? preview.pages.map((p) => surfaceFor(p)?.label || p).join(', ')
: 'None declared yet'}
</dd>
</div>
{preview.owliver.suggestions.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Suggested</dt>
{/* Drawn as the panel draws them, so the author can see
whether they read as buttons rather than sentences. */}
<dd className="mt-1 flex flex-wrap gap-1.5">
{preview.owliver.suggestions.map((s) => (
<span
key={s.label}
className="rounded-full border border-border bg-surface px-2.5 py-1 text-caption font-medium text-ink-2 shadow-xs"
>
{s.label}
</span>
))}
</dd>
</div>
)}
{preview.owliver.capabilities.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Capabilities</dt>
<dd className="mt-1 space-y-1">
{preview.owliver.capabilities.map((c) => (
<div key={c} className="flex flex-wrap items-baseline gap-x-2">
<span className="text-[11px] font-medium text-ink-1">
{owliverCapabilityLabel(c)}
</span>
<span className="text-[11px] text-ink-3">
{dataSourceLabel(preview.owliver.responses[c].source)}
</span>
{preview.owliver.responses[c].periods.length > 0 && (
<span className="text-[11px] text-ink-4">
{preview.owliver.responses[c].periods.map(periodLabel).join(', ')}
</span>
)}
</div>
))}
</dd>
</div>
)}
{preview.triggers.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Triggers</dt>
<dd className="text-[11px] leading-relaxed text-ink-3">
{preview.triggers.join(' · ')}
</dd>
</div>
)}
{/* The same definition's page half, if it has one. This
editor does not configure it, but a definition doing both
jobs must not look like it only does this one. */}
{uiSections.length > 0 && (
<div className="rounded-lg border border-border bg-surface-subtle p-2.5">
<dt className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
<LayoutTemplate className="h-3 w-3" aria-hidden="true" />
Also a UI skill
</dt>
<dd className="mt-1 space-y-1">
<p className="text-[11px] leading-relaxed text-ink-3">
This definition also draws{' '}
{uiSections.map((s) => sectionTypeLabel(s.type).toLowerCase()).join(', ')}
{' '}on its pages. Saving here keeps that exactly as written.
</p>
<button
type="button"
onClick={() => navigate(`/admin/workspace/skills/${preview.id}`)}
className="text-[11px] font-semibold text-krow-blue hover:underline"
>
Edit its page section →
</button>
</dd>
</div>
)}
{preview.owliverErrors?.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-destructive">
Owliver configuration
</dt>
<dd className="mt-1 space-y-1">
{preview.owliverErrors.map((message) => (
<p key={message} className="text-[11px] leading-relaxed text-destructive">
{message}
</p>
))}
</dd>
</div>
)}
</dl>
</>
) : (
<p className="text-caption text-ink-3">
The definition could not be parsed yet. Keep the frontmatter block at the top.
</p>
)}
</Surface>
</aside>
</div>
</AdminPage>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ArrowRight, CheckCircle2, LayoutGrid, List, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
@@ -18,6 +18,8 @@ import {
import {
PositionCustomRequirements, PositionOverview, PositionRequirements,
} from '@/components/krow/PositionDetails';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { usePublishPageContext } from '@/components/ai-assistant';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
@@ -272,6 +274,78 @@ function useViewMode() {
}];
}
/* ── Just saved ────────────────────────────────────────────────────────────
*
* A position saved in the last two minutes is held a shade closer to the brand
* on its own card, so an admin returning to a list of thirty roles can find the
* one they were just working on without a toast telling them where to look.
*
* This is emphasis only, and it is the *only* part of the card that expires.
* What the card says about the position — Draft, Hiring, Paused — is the
* position's own status and is never on a timer: a draft stays a draft until
* the publish action makes it something else.
*
* It is derived from `updated_date`, which the store stamps on every write, so
* the mark follows the last save rather than the first. Because the source is
* the record itself, it survives a refresh, a navigation away and back, and any
* filter or sort the page is in. Each position expires on its own clock; there
* is no page-level "something happened" flag.
*/
const RECENT_WINDOW_MS = 2 * 60 * 1000;
const savedAt = (posting) => Date.parse(posting.updated_date || posting.created_date);
/**
* The ids currently inside their window, and a single timer that re-reads the
* clock when the soonest of them expires. Nothing recent on screen means no
* timer at all.
*/
function useRecentlySavedIds(postings) {
const [now, setNow] = useState(() => Date.now());
/* The creation times themselves, as a value rather than an array identity.
The page re-reads `postings` from the query on every render, so depending
on the array would re-run the effects below on renders where nothing about
the data changed. */
const stamps = useMemo(
() => postings.map((p) => `${p.id}@${p.updated_date || p.created_date}`).join('|'),
[postings]
);
/* The clock is re-read whenever a position is added or saved, not only when a
timer fires. A position written while this page is already open arrives
through a query invalidation, and against a clock last read at mount it
would look like it had not happened yet. */
useEffect(() => { setNow(Date.now()); }, [stamps]);
const recentIds = useMemo(() => {
const ids = new Set();
for (const p of postings) {
const age = now - savedAt(p);
if (Number.isFinite(age) && age >= 0 && age < RECENT_WINDOW_MS) ids.add(p.id);
}
return ids;
/* `stamps` stands in for `postings` here: the same information, as a value
that only changes when the set of positions does. */
}, [stamps, now]);
useEffect(() => {
if (!recentIds.size) return undefined;
/* Wake once, at the first expiry rather than on an interval. Whatever is
still recent after that wake re-arms the next one. */
const soonest = Math.min(
...postings
.filter((p) => recentIds.has(p.id))
.map((p) => savedAt(p) + RECENT_WINDOW_MS)
);
const timer = setTimeout(() => setNow(Date.now()), Math.max(0, soonest - Date.now()) + 50);
return () => clearTimeout(timer);
}, [recentIds]);
return recentIds;
}
/**
* The same positions, as a table.
*
@@ -428,9 +502,17 @@ function candidateCriteria(position) {
].filter(Boolean).join(' · ') || null;
}
/** One position. The whole card is the target, so there is no hunting for a link. */
function PositionCard({ position, onOpen }) {
/**
* One position. The whole card is the target, so there is no hunting for a link.
*
* A draft opens where a draft belongs — back in the form that was writing it —
* while everything else opens the drawer it always did. That is the only
* difference between the two states on this card: same layout, same figures,
* and the status pill the card has always drawn now reads Draft.
*/
function PositionCard({ position, onOpen, isRecent = false }) {
const p = position;
const isDraft = p.status === 'draft';
return (
<article
@@ -443,11 +525,17 @@ function PositionCard({ position, onOpen }) {
onOpen(p);
}
}}
aria-label={`${p.title}. ${p.health.label}. ${p.stats.applied} applicants. ${p.insight}`}
className="group flex cursor-pointer flex-col rounded-xl border border-border bg-surface p-4 text-left shadow-xs
transition-[border-color,box-shadow] duration-base ease-out
hover:border-krow-blue/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
aria-label={`${p.title}. ${p.health.label}. ${p.stats.applied} applicants. ${p.insight}${isDraft ? '. Draft — not published yet.' : ''}`}
className={cn(
`group flex cursor-pointer flex-col rounded-xl border border-border bg-surface p-4 text-left shadow-xs
transition-[border-color,box-shadow] duration-base ease-out
hover:border-krow-blue/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
/* Just saved: the same card, held a shade closer to the brand for two
minutes. Border, ring and surface only — the layout does not move,
and the entrance settles once rather than pulsing. */
isRecent && 'border-krow-blue/50 bg-krow-blue-tint/40 ring-1 ring-krow-blue/20 motion-safe:animate-scale-in'
)}
>
{/* Identity */}
<div className="flex items-start gap-3">
@@ -469,6 +557,9 @@ function PositionCard({ position, onOpen }) {
<p className="truncate text-caption text-ink-3">{p.role_category || 'Uncategorized'}</p>
</div>
{/* The position's own status, in the pill this card has always drawn.
A draft says Draft here — nothing else is added, and nothing about
this is on a timer. */}
<StatusBadge status={p.status} size="sm" />
</div>
@@ -516,11 +607,31 @@ function PositionCard({ position, onOpen }) {
? <><Sparkles className="h-3 w-3" aria-hidden="true" /> AI Built</>
: 'Manual'}
</span>
{/* A draft is unfinished work, so the card says what happens next on it:
it goes back to the form, not to a hiring drawer for a role nobody
can apply to yet. */}
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-krow-blue">
View position
{isDraft ? 'Continue' : 'View position'}
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
</span>
</div>
{/* The card's own extension point. A definition can target the list and
the drawer separately, or both — `after-position-card` renders per
card, with that card's position as context. Clicks inside a section
are its own; they do not open the drawer behind it. */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<SkillSurface
page="positions"
placement="after-position-card"
context={{ position: p }}
className="mt-3"
/>
</div>
</article>
);
}
@@ -595,6 +706,12 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
</div>
</div>
{/* Extension points. A skill definition naming `positions` and one of
these placements renders here — this drawer is what "View position"
opens, so it is where a positions skill has to appear. The page
knows no skill; the definition names the slot. */}
<SkillSurface page="positions" placement="after-header" context={{ position: p }} />
{/* The position as it was specified. Every field Create Position asks
for is read back here — the three sections below mirror the three
groups of that form, in the same order. */}
@@ -603,6 +720,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<PositionOverview position={p} className="mt-2" />
</section>
<SkillSurface page="positions" placement="after-position-summary" context={{ position: p }} />
{/* Only what this role actually asks for. A position that states no
requirements has no Requirements section — an empty heading is a
demand nobody made. */}
@@ -660,6 +779,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<Progression steps={p.progression} />
</section>
<SkillSurface page="positions" placement="before-candidates" context={{ position: p }} />
{/* Two short lists side by side. The extra drawer width is spent on
information density rather than on longer lines of the same stack. */}
<div className="grid gap-5 lg:grid-cols-2">
@@ -712,6 +833,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
</section>
</div>
<SkillSurface page="positions" placement="after-candidates" context={{ position: p }} />
<section>
<SectionTitle title="Recent activity" />
{p.applications.length ? (
@@ -736,6 +859,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<p className="text-body-sm text-ink-3">No activity on this position yet.</p>
)}
</section>
<SkillSurface page="positions" placement="before-footer" context={{ position: p }} />
</div>
</Drawer>
);
@@ -767,6 +892,44 @@ export default function AdminPositions() {
const [selected, setSelected] = useState(null);
const [view, setView] = useViewMode();
/* Read off the postings, not the filtered rows: which positions were saved
recently does not depend on what is currently on screen, so searching or
sorting cannot shift the window. */
const recentIds = useRecentlySavedIds(postings);
/**
* Continuing a draft.
*
* The same authoring form, on the same route, told which record it is
* finishing. Not a second editor and not a copy of the values: the form loads
* the draft and saves back to it, so publishing from there is the one publish
* action there has ever been.
*/
const openPosition = (p) => (p.status === 'draft'
? navigate(`/admin/positions/new?draft=${encodeURIComponent(p.id)}`)
: setSelected(p));
/**
* The position the reader has open, published for Owliver.
*
* The panel beside this page can read every collection already; what it
* cannot know is which record is in front of you. Publishing the raw posting
* — not the derived row — means a skill's declared source resolves against
* the same record the drawer is showing, and reading a different position
* changes the answer without either side knowing about the other.
*
* A position that has just been published counts as "in front of you" too: the
* form leaves `?published=<id>` behind it, which is how the panel knows which
* role to offer candidates for without this page telling it anything about
* candidates. The drawer still wins while one is open.
*/
const publishedId = searchParams.get('published');
usePublishPageContext(useMemo(() => {
const current = selected?.id || publishedId;
if (!current) return null;
return { position: postings.find((p) => p.id === current) || null };
}, [selected, publishedId, postings]));
/**
* Derived once per position rather than per cell, so the grid does not run the
* same filter five times per card.
@@ -858,30 +1021,37 @@ export default function AdminPositions() {
title="Positions"
subtitle="Manage open roles, hiring demand and position performance."
>
{/* Controls. Search gets a fixed, sensible measure — wide enough for a role
title, not so wide it becomes the page's subject. */}
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="w-full lg:w-80">
<SearchInput value={search} onChange={setSearch} placeholder="Search positions" size="sm" />
{/* Controls toolbar — unified flex row with clean alignment across viewports */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2 flex-1 min-w-0">
<div className="w-full sm:w-60 md:w-64 xl:w-72 shrink-0">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search positions"
size="sm"
shape="pill"
className="bg-white border-border/80 hover:border-border transition-colors"
/>
</div>
<div className="flex flex-wrap items-center gap-2 min-w-0">
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
{ value: 'all', label: 'All' }, { value: 'active', label: 'Hiring' },
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Department"
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
{ value: 'all', label: 'All statuses' }, { value: 'active', label: 'Hiring' },
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Department"
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
{/* Presentation, at the end of the same toolbar. It changes how the rows
are drawn, never which rows or what they say. */}
<div className="lg:ml-auto">
{/* View mode presentation toggle on far right */}
<div className="shrink-0 self-end lg:self-center">
<SegmentedToggle
variant="surface"
ariaLabel="View mode"
@@ -895,10 +1065,16 @@ export default function AdminPositions() {
</div>
</div>
<p className="text-caption text-ink-4">
<p className="text-body-sm text-ink-3 font-normal mt-1 mb-4 flex items-center gap-1.5 flex-wrap">
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
</p>
{/* The list's own extension points. These render once for the page — no
position in context — which is what a definition reporting across
every role needs. The per-card and per-drawer placements are separate
slots, so one skill can address the board and another one role. */}
<SkillSurface page="positions" placement="after-position-list-summary" className="mb-4" />
{/* Three columns on desktop, two on tablet, one on mobile. */}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
@@ -911,11 +1087,16 @@ export default function AdminPositions() {
view === 'grid' ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((p) => (
<PositionCard key={p.id} position={p} onOpen={setSelected} />
<PositionCard
key={p.id}
position={p}
onOpen={openPosition}
isRecent={recentIds.has(p.id)}
/>
))}
</div>
) : (
<PositionsTable rows={filtered} onOpen={setSelected} />
<PositionsTable rows={filtered} onOpen={openPosition} />
)
) : (
<EmptyState
@@ -933,6 +1114,8 @@ export default function AdminPositions() {
/>
)}
<SkillSurface page="positions" placement="after-position-list" className="mt-6" />
<PositionDrawer
position={selected}
onClose={() => setSelected(null)}
@@ -947,11 +1130,17 @@ export default function AdminPositions() {
);
}
/** A compact labelled select — the same control on every Admin management page. */
/** A compact labelled select pill — consistent on every Admin management page. */
export function FilterSelect({ value, onChange, label, options, className }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger aria-label={label} className={cn('h-8 w-auto min-w-[8.5rem] text-body-sm', className)}>
<SelectTrigger
aria-label={label}
className={cn(
'h-8 w-auto min-w-[7.5rem] rounded-full border border-border/80 bg-white px-3 text-[13px] font-normal text-ink-1 shadow-2xs hover:bg-surface-hover hover:border-border transition-colors outline-none focus:ring-2 focus:ring-krow-blue/20',
className
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -12,6 +12,7 @@ import { useCurrentUser, useUpdateProfile, useUserActivity } from '@/lib/krowHoo
import { AdminPage } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
import { PERMISSIONS } from '@/pages/admin/permissions';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Admin Profile — Premium enterprise account & security control center.
@@ -96,6 +97,7 @@ export default function AdminProfile() {
onClick={() => navigate('/admin/settings')}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-white border border-[#E0E4EA] text-[#101820] text-[13px] font-semibold hover:bg-[#F8F8F8] transition-all shadow-xs"
>
<SkillSurface page="profile" placement="after-header" />
<SettingsIcon className="w-4 h-4 text-[#687386]" />
Settings
</button>
@@ -117,6 +119,7 @@ export default function AdminProfile() {
}
>
<div className="space-y-6">
{/* ── 1. Hero Identity Banner ────────────────────────────────────────────── */}
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#0838E0] via-[#1E40AF] to-[#0284C7] p-6 sm:p-8 text-white shadow-xl">
{/* Subtle background ambient mesh */}
@@ -487,6 +490,7 @@ export default function AdminProfile() {
</div>
</Modal>
</div>
<SkillSurface page="profile" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -50,7 +50,7 @@ export default function AdminSettings() {
<Sliders className="w-3.5 h-3.5 text-sky-300" />
Workspace Control Center
</div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">System Settings & Governance</h1>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">Settings </h1>
<p className="text-[14px] text-blue-100 font-normal max-w-2xl">
Configure account credentials, access security policies, organizational workforce units, and system automation behavior.
</p>

View File

@@ -1,16 +1,33 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft, Upload } from 'lucide-react';
import { ChevronLeft, Sparkles, Upload } from 'lucide-react';
import {
Badge, Button, Field, Input, Surface, Switch, Textarea, toast,
Badge, Button, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
import { customSkillSource, skillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import { customSkillSource, uiSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import {
DATA_SOURCES, PERIODS, SECTION_TYPES, dataSourceLabel, owliverCapabilityLabel, periodLabel,
placementLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Add or edit an Owliver skill, as a page.
* Add or edit a UI skill, as a page.
*
* One of two editors. This one configures what a definition draws on a KROW
* page — the section, where it sits, what it reads. What the same definition
* lets Owliver be *asked* for is configured in the Owliver Skill Editor, and
* the two were one form until an author had to know which half of it applied
* to them.
*
* The split is in the fields only. Both editors compose the same Markdown,
* validate it with the same validator and store it through the same helper, and
* a definition that does both jobs is saved whole by either — which is why the
* preview here still reports the Owliver half rather than hiding it.
*
* The dialog this replaces still exists — Owliver's own header opens it, where a
* modal is the right shape for "add one while you are mid-conversation". This is
@@ -22,15 +39,32 @@ import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
* door, not a second system.
*/
/** The frontmatter fields, kept in step with the Markdown until it is edited. */
const emptyMeta = { id: '', name: '', description: '', pages: [] };
/** The fields this editor composes, kept in step with the Markdown until it is edited. */
const emptyMeta = {
id: '', name: '', description: '', pages: [],
type: 'flow', placement: '', source: 'position.activity', periods: [],
};
/** Reads the meta back out of a definition, for editing an existing skill. */
/**
* Reads the fields back out of a definition, for editing an existing skill.
*
* The section is read from what the registry made of the file rather than from
* the Markdown text, so the form shows what is actually registered — including
* a placement the author left out and the vocabulary filled in.
*/
function metaFromSource(source) {
try {
const skill = parseSkill(source, { custom: true });
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
return {
id: skill.id, name: skill.name, description: skill.description, pages: skill.pages,
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
type: section?.type || emptyMeta.type,
placement: section?.placement || '',
source: section?.source || emptyMeta.source,
periods: section?.periods || [],
};
} catch {
return emptyMeta;
@@ -56,14 +90,13 @@ export default function SkillEditor() {
* override rather than a duplicate.
*/
const initial = useMemo(() => {
if (!editingId) return skillTemplate({});
if (!editingId) return uiSkillTemplate({});
const stored = customSkillSource(customSkills, editingId);
if (stored) return stored;
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
return shipped?.markdown || skillTemplate({});
/* Loaded once per skill: retyping in the editor must not be overwritten by
a preferences write elsewhere in the app. */
// eslint-disable-next-line react-hooks/exhaustive-deps
return shipped?.markdown || uiSkillTemplate({});
/* Deliberately keyed on the skill alone: retyping in the editor must not be
overwritten by a preferences write elsewhere in the app. */
}, [editingId]);
const [source, setSource] = useState(initial);
@@ -73,6 +106,19 @@ export default function SkillEditor() {
const [error, setError] = useState('');
const fileRef = React.useRef(null);
const selectedPages = useMemo(
() => pagesText.split(',').map((p) => p.trim()).filter(Boolean),
[pagesText]
);
/* The placements the chosen surfaces actually mount. A page can only be
extended where it offers a slot, so this list is the surfaces' own — never
a fixed set of names typed here. */
const placements = useMemo(
() => [...new Set(selectedPages.flatMap((p) => surfaceFor(p)?.placements || []))],
[selectedPages]
);
/* The parsed view of what is in the editor right now — what actually gets
registered, so the preview cannot flatter the definition. */
const preview = useMemo(() => {
@@ -95,12 +141,16 @@ export default function SkillEditor() {
const declaredActive = preview?.status !== 'inactive';
const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
/* Every section this definition registers, flattened — the preview reads it
for both the type line and the configuration block. */
const uiSections = Object.values(preview?.ui || {}).flatMap((page) => page.sections || []);
/* Typing in the fields composes frontmatter until the Markdown is edited by
hand, after which the Markdown wins — it is the thing being saved. */
const syncFromMeta = (next, nextPagesText = pagesText) => {
setMeta(next);
if (touched) return;
setSource(skillTemplate({
setSource(uiSkillTemplate({
...next,
pages: nextPagesText.split(',').map((p) => p.trim()).filter(Boolean),
}));
@@ -166,8 +216,8 @@ export default function SkillEditor() {
</>
}
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<div className="space-y-5">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px] xl:grid-cols-[1fr_400px] 2xl:grid-cols-[1fr_440px] lg:items-start w-full">
<div className="space-y-6 min-w-0">
<section className="space-y-3">
<SectionTitle title="Skill" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
@@ -199,16 +249,52 @@ export default function SkillEditor() {
<Field
label="Surfaces"
required
hint={`The pages this skill attaches to. Comma separated. Known pages: ${PAGE_KEYS.join(', ')}`}
hint="Select the pages this skill attaches to from the options below or dropdown list."
>
<Input
value={pagesText}
placeholder="positions, candidates"
onChange={(e) => {
setPagesText(e.target.value);
syncFromMeta(meta, e.target.value);
}}
/>
<div className="space-y-2.5">
<MultiSelect
options={PAGE_KEYS.map((k) => ({ value: k, label: k }))}
value={selectedPages}
onChange={(next) => {
const nextStr = next.join(', ');
setPagesText(nextStr);
syncFromMeta(meta, nextStr);
}}
placeholder="Select surfaces (e.g. positions, candidates...)"
maxChips={6}
/>
{/* Interactive Option List for 1-click toggling */}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-[11px] font-medium text-ink-4 mr-1">Available surfaces:</span>
{PAGE_KEYS.map((pageKey) => {
const isSelected = selectedPages.includes(pageKey);
return (
<button
key={pageKey}
type="button"
onClick={() => {
const next = isSelected
? selectedPages.filter((p) => p !== pageKey)
: [...selectedPages, pageKey];
const nextStr = next.join(', ');
setPagesText(nextStr);
syncFromMeta(meta, nextStr);
}}
className={cn(
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-caption transition-all cursor-pointer border',
isSelected
? 'bg-krow-blue-tint text-krow-blue border-krow-blue/30 font-medium shadow-2xs'
: 'bg-surface-sunken/60 text-ink-3 border-border/60 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{pageKey}</span>
<span className="text-[10px] font-bold opacity-75">{isSelected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</div>
</Field>
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
@@ -223,9 +309,102 @@ export default function SkillEditor() {
</Surface>
</section>
{/* ── What it draws ──────────────────────────────────────────
The section itself: its shape, where it sits and what it reads.
Every option comes from the closed vocabulary in `surfaces.js`, so
this form cannot offer a component the product does not ship or a
placement no page mounts. */}
<section className="space-y-3">
<SectionTitle title="UI configuration" meta="What this skill draws" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Component" hint="The shape the section takes.">
<Select value={meta.type} onValueChange={(type) => syncFromMeta({ ...meta, type })}>
<SelectTrigger><SelectValue placeholder="Select a component" /></SelectTrigger>
<SelectContent>
{SECTION_TYPES.map((t) => (
<SelectItem key={t.id} value={t.id}>{t.label} — {t.summary}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field
label="Placement"
hint={placements.length
? 'Where on the page the section sits.'
: 'Select a surface above and its placements appear here.'}
>
<Select
value={meta.placement || undefined}
onValueChange={(placement) => syncFromMeta({ ...meta, placement })}
disabled={!placements.length}
>
<SelectTrigger><SelectValue placeholder="First placement on the surface" /></SelectTrigger>
<SelectContent>
{placements.map((placement) => (
<SelectItem key={placement} value={placement}>{placementLabel(placement)}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<Field
label="Data source"
hint="A named reading of data KROW already holds. An Owliver skill naming the same source reads exactly the same records."
>
<Select value={meta.source} onValueChange={(source) => syncFromMeta({ ...meta, source })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((src) => (
<SelectItem key={src.id} value={src.id}>{src.label} — {src.summary}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
const selected = meta.periods.includes(period.id);
return (
<button
key={period.id}
type="button"
onClick={() => syncFromMeta({
...meta,
periods: selected
? meta.periods.filter((x) => x !== period.id)
: [...meta.periods, period.id],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{period.label}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</Field>
{touched && (
<p className="border-t border-border/50 pt-3 text-caption leading-relaxed text-ink-4">
The definition below has been edited by hand, so it is what gets saved — these
fields no longer compose it.
</p>
)}
</Surface>
</section>
<section className="space-y-3">
<SectionTitle title="Instructions" meta="Markdown" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Field
label="Skill definition"
hint="This is what gets saved. Frontmatter declares the skill; the body documents what it can do."
@@ -235,7 +414,7 @@ export default function SkillEditor() {
value={source}
rows={22}
spellCheck={false}
className="font-mono text-[12px] leading-relaxed"
className="font-mono text-[12.5px] leading-relaxed bg-surface-sunken/40 border-border/80 focus:border-krow-blue rounded-lg p-3.5 transition-all"
onChange={(e) => {
setSource(e.target.value);
setTouched(true);
@@ -244,17 +423,19 @@ export default function SkillEditor() {
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileRef}
type="file"
accept=".md,text/markdown"
onChange={readFile}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/50 pt-3">
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept=".md,text/markdown"
onChange={readFile}
className="hidden"
/>
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
</div>
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
</div>
</Surface>
@@ -262,7 +443,7 @@ export default function SkillEditor() {
</div>
{/* What the registry will make of this definition, as it stands. */}
<aside className="space-y-3">
<aside className="space-y-3 lg:sticky lg:top-20">
<SectionTitle title="Preview" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
{preview ? (
@@ -290,9 +471,108 @@ export default function SkillEditor() {
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Type</dt>
<dd className="text-caption text-ink-2">
{preview.kind === 'workforce' ? 'Workforce path' : 'Assistant'}
{/* A definition that configures UI is read by what it
draws; one that does not is read by what it is. */}
{uiSections.length
? uiSections.map((s) => sectionTypeLabel(s.type)).join(', ')
: preview.kind === 'workforce' ? 'Workforce path' : 'Assistant'}
</dd>
</div>
{/* What the declarative `ui:` block will add to the product.
A sketch of the section, not a live one: the shape,
placement and source are real, the figures come from the
page at render time rather than from this editor. */}
{uiSections.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
UI configuration
</dt>
<dd className="mt-1 space-y-2">
{Object.entries(preview.ui).map(([page, config]) => (
<div key={page} className="rounded-lg border border-border bg-surface-subtle p-2.5">
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">
{surfaceFor(page)?.label || page}
</p>
{config.sections.map((section) => (
<dl key={section.id} className="mt-1.5 space-y-1">
{section.title && (
<p className="text-caption font-medium text-ink-1">{section.title}</p>
)}
<div className="flex gap-2">
<dt className="w-20 shrink-0 text-[11px] text-ink-4">Placement</dt>
<dd className="text-[11px] text-ink-2">{placementLabel(section.placement)}</dd>
</div>
<div className="flex gap-2">
<dt className="w-20 shrink-0 text-[11px] text-ink-4">Source</dt>
<dd className="text-[11px] text-ink-2">{dataSourceLabel(section.source)}</dd>
</div>
{section.periods.length > 0 && (
<div className="flex gap-2">
<dt className="w-20 shrink-0 text-[11px] text-ink-4">Periods</dt>
<dd className="flex flex-wrap gap-1">
{section.periods.map((period) => (
<span
key={period}
className="rounded border border-border bg-surface px-1.5 py-0.5 text-[10px] text-ink-3"
>
{periodLabel(period)}
</span>
))}
</dd>
</div>
)}
</dl>
))}
</div>
))}
</dd>
</div>
)}
{/* The panel half of the same definition, when it has one.
Not configurable here — that is the other editor's job —
but a definition that also answers questions must not read
on this page as if it only drew a card. */}
{preview.owliver?.enabled && preview.owliver.capabilities.length > 0 && (
<div className="rounded-lg border border-border bg-surface-subtle p-2.5">
<dt className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
<Sparkles className="h-3 w-3" aria-hidden="true" />
Also an Owliver skill
</dt>
<dd className="mt-1 space-y-1">
<p className="text-[11px] leading-relaxed text-ink-3">
Owliver can be asked for{' '}
{preview.owliver.capabilities
.map((c) => owliverCapabilityLabel(c).toLowerCase())
.join(' and ')}
{' '}on these pages. Saving here keeps that exactly as written.
</p>
<button
type="button"
onClick={() => navigate(`/admin/workspace/skills/owliver/${preview.id}`)}
className="text-[11px] font-semibold text-krow-blue hover:underline"
>
Edit its Owliver behaviour →
</button>
</dd>
</div>
)}
{preview.uiErrors?.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-destructive">
UI configuration
</dt>
<dd className="mt-1 space-y-1">
{preview.uiErrors.map((message) => (
<p key={message} className="text-[11px] leading-relaxed text-destructive">
{message}
</p>
))}
</dd>
</div>
)}
{preview.capabilities.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Capabilities</dt>

View File

@@ -8,6 +8,7 @@ import { useWorkerProfiles } from '@/lib/krowHooks';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Admin Talent Pool — talent intelligence, not a card gallery.
@@ -158,6 +159,7 @@ export default function AdminTalentPool() {
title="Talent Pool"
subtitle="Discover and manage high-potential talent for future hiring."
>
<SkillSurface page="talent-pool" placement="after-header" />
{/* Segments — executive metric cards */}
<section aria-labelledby="segments" className="space-y-3">
<SectionTitle
@@ -359,6 +361,7 @@ export default function AdminTalentPool() {
},
]}
/>
<SkillSurface page="talent-pool" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -1,14 +1,21 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronLeft, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles, Trash2,
} from 'lucide-react';
import {
Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuTrigger, EmptyState, SearchInput, Surface, Switch, toast,
DropdownMenuTrigger, EmptyState, SearchInput, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Tabs, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { allSkills, contextIdsForSkill } from '@/lib/skills/registry';
import { removeCustomSkill } from '@/lib/skills/customSkills';
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
import { PAGE_KEYS, allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { removeCustomSkill, upsertCustomSkill } from '@/lib/skills/customSkills';
import { countSections } from '@/lib/skills/uiConfig';
import {
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage } from '@/pages/admin/_shell';
/**
@@ -20,21 +27,85 @@ import { AdminPage } from '@/pages/admin/_shell';
* A registry deserves an address, so this is one — reachable from Workspace &
* Skills, where Owliver's capabilities belong, and linkable on its own.
*
* Nothing here is a second skill system. The rows are `allSkills()` — the
* Markdown files on disk plus whatever the account has added — and enabling or
* disabling one writes the same `disabledSkills` preference it always did.
* Two lists, because there are two jobs. A **UI skill** extends a KROW page: it
* declares a section, a placement and a reading, and the page draws it. An
* **Owliver skill** extends the assistant: it declares triggers, suggestions
* and the shapes an answer can take. Reading one list and finding both mixed
* together was the problem this page had — "pages" meant "where it draws" in
* one row and "where it listens" in the next, and nothing on screen said which.
*
* What is *not* split is as important. Both lists are readings of `allSkills()`
* — one registry, one parser, one validator, one persistence, one data
* resolver. A definition's facets are derived from what it declares, so this
* page classifies rather than configures, and nothing stored had to be
* migrated for the split to exist.
*/
const KIND_LABEL = {
assistant: 'Assistant',
workforce: 'Workforce path',
};
/** The two lists, and the words each one uses for itself. */
const FACETS = [
{
id: 'ui',
label: 'Board Skills',
icon: LayoutTemplate,
add: 'Add Board Skill',
route: '/admin/workspace/skills/new',
blurb: 'Definitions that add dynamic sections to KROW pages, read from real page data.',
emptyTitle: 'No Board skills yet',
emptyBody: 'A Board skill declares a section, where it sits and what it reads. Add one and the page it names renders it.',
},
{
id: 'owliver',
label: 'Owliver Skills',
icon: Sparkles,
add: 'Add Owliver Skill',
route: '/admin/workspace/skills/owliver/new',
blurb: 'Definitions that teach Owliver what it can be asked for on a page, and how to answer.',
emptyTitle: 'No Owliver skills yet',
emptyBody: 'An Owliver skill declares triggers, suggestions and the shapes its answers take. Add one and the panel on that page offers it.',
},
];
const facetFor = (id) => FACETS.find((f) => f.id === id) || FACETS[0];
/** The page labels a definition attaches to, in the product's own words. */
const surfaceLabels = (skill) =>
skill.pages.map((p) => surfaceFor(p)?.label || p).filter(Boolean);
/**
* What this definition contributes, in the terms of the list it is on.
*
* The same skill answers the question differently depending on which job is
* being managed: on the UI list it is a shape and a placement, on the Owliver
* list it is the capabilities it can be asked for. Derived from the definition
* both times — nothing here is a stored label.
*/
function contribution(skill, facet) {
if (facet === 'ui') {
const sections = Object.values(skill.ui || {}).flatMap((page) => page.sections || []);
return {
type: [...new Set(sections.map((s) => sectionTypeLabel(s.type)))].join(' · '),
detail: [...new Set(sections.map((s) => placementLabel(s.placement)))].join(', '),
};
}
const capabilities = skill.owliver?.capabilities || [];
return {
type: capabilities.map(owliverCapabilityLabel).join(' · '),
/* A definition written before the split has no capability list — what it
offers Owliver is its triggers, so say that rather than nothing. */
detail: capabilities.length
? `${skill.owliver.suggestions.length || 'No'} suggestion${skill.owliver.suggestions.length === 1 ? '' : 's'}`
: `${skill.triggers.length} trigger${skill.triggers.length === 1 ? '' : 's'}`,
};
}
/** One skill, as a row in the management list. */
function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
const pages = contextIdsForSkill(skill)
.map((id) => ASSISTANT_CONTEXTS[id]?.page)
.filter(Boolean);
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
const pages = surfaceLabels(skill);
const { type, detail } = contribution(skill, facet);
/* A definition doing both jobs appears on both lists. Saying so on the row is
what stops it reading as a duplicate. */
const both = skill.facets.length > 1;
return (
<div className="grid gap-3 px-4 py-3.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
@@ -44,30 +115,24 @@ function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
<Badge variant={enabled ? 'success' : 'neutral'} size="sm">
{enabled ? 'Active' : 'Inactive'}
</Badge>
<Badge variant="soft" size="sm">{KIND_LABEL[skill.kind] || 'Assistant'}</Badge>
{type && <Badge variant="soft" size="sm">{type}</Badge>}
{skill.custom && <Badge variant="soft" size="sm">Custom</Badge>}
{both && <Badge variant="neutral" size="sm">Also on the other list</Badge>}
</div>
{skill.description && (
<p className="mt-1 text-caption leading-relaxed text-ink-3">{skill.description}</p>
)}
{/* A workforce path names the capability it governs and the rungs it
defines: on this list those are what change other pages. */}
{skill.kind === 'workforce' && (
<p className="mt-1.5 text-[11px] text-ink-4">
Capability: <span className="text-ink-3">{skill.skillId}</span>
{skill.levels.length > 0 && <> · {skill.levels.map((l) => l.label).join(' → ')}</>}
</p>
)}
{pages.length > 0 && (
<p className="mt-1.5 text-[11px] text-ink-4">
Surfaces: <span className="text-ink-3">{pages.join(', ')}</span>
</p>
)}
<p className="mt-1.5 text-[11px] text-ink-4">
{pages.length > 0 && (
<>Pages: <span className="text-ink-3">{pages.join(', ')}</span></>
)}
{pages.length > 0 && detail && ' · '}
{detail && <span className="text-ink-3">{detail}</span>}
</p>
<p className="mt-1 text-[11px] text-ink-4">
{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'}
{' · '}
<span className="font-mono">{skill.path}</span>
@@ -96,14 +161,16 @@ function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
<Pencil className="mr-2 h-4 w-4" />
{skill.custom ? 'Edit skill' : 'Duplicate & edit'}
</DropdownMenuItem>
{skill.custom && (
<DropdownMenuItem
onClick={() => onDelete(skill)}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> Remove skill
</DropdownMenuItem>
)}
{/* Offered for every definition. What it does differs — an account
definition is deleted, a shipped one is taken out of this
workspace — and the dialog says which. */}
<DropdownMenuItem
onClick={() => onDelete(skill)}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{skill.custom ? 'Remove skill' : 'Remove from workspace'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -116,56 +183,240 @@ export default function WorkspaceSkills() {
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
/* Which list is open lives in the URL, so a link can open one and the back
button steps between them rather than out of the page. */
const [params, setParams] = useSearchParams();
const facet = FACETS.some((f) => f.id === params.get('tab')) ? params.get('tab') : 'ui';
const setFacet = (next) => setParams(next === 'ui' ? {} : { tab: next }, { replace: true });
const [search, setSearch] = useState('');
const [status, setStatus] = useState('all');
const [page, setPage] = useState('all');
const [pendingDelete, setPendingDelete] = useState(null);
const [showRemoved, setShowRemoved] = useState(false);
const disabledSkills = preferences.disabledSkills || [];
/**
* Definitions this account has removed.
*
* A shipped definition lives in the repository, so "remove" cannot mean
* deleting it — the file would come back on the next deployment and the
* button would be lying. It means removed *from this workspace*: hidden from
* this list and switched off everywhere.
*
* The second half is why removal needs no other file to change. A removed id
* is also written to `disabledSkills`, which every surface, resolver and
* suggestion path already honours — so "removed" is exactly "disabled, and
* out of the way", and nothing had to learn a second rule.
*/
const removedSkills = preferences.removedSkills || [];
const customSkills = preferences.customSkills || [];
const skills = useMemo(() => allSkills(customSkills), [customSkills]);
/**
* Whether a skill is actually offered.
*
* Two independent things switch a skill off — the definition declaring
* `status: inactive`, and this account disabling it — and the renderer honours
* both. This list used to read only the second, so a definition that ships
* inactive appeared here as "Active" while every surface correctly refused to
* render it. The Skill Editor already reconciled the two; now so does this.
*/
const isEnabled = (skill) =>
skill.status !== 'inactive' && !disabledSkills.includes(skill.id);
/**
* Everything still in this workspace.
*
* Removal is filtered out *here*, at the source, rather than where the rows
* are drawn — because four things count these definitions: the two tab
* badges, the page filter's options, and the "N registered · N active"
* footer. Filtering at render would leave a tab reading "4" above an empty
* list and a footer counting definitions the reader has removed.
*/
const present = useMemo(
() => skills.filter((s) => !removedSkills.includes(s.id)),
/* Keyed on the content: `preferences` rebuilds its arrays every read. */
// eslint-disable-next-line react-hooks/exhaustive-deps
[skills, removedSkills.join('|')]
);
/** The definitions on this list, before the management filters. */
const listed = useMemo(() => skillsWithFacet(present, facet), [present, facet]);
/** The removed ones, for the drawer that lets them be put back. */
const removed = useMemo(
() => skills.filter((s) => removedSkills.includes(s.id)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[skills, removedSkills.join('|')]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return skills;
return skills.filter((s) => `${s.name} ${s.description} ${s.pages.join(' ')}`.toLowerCase().includes(q));
}, [skills, search]);
return listed.filter((s) => {
if (q && !`${s.name} ${s.description} ${s.pages.join(' ')}`.toLowerCase().includes(q)) return false;
if (status === 'active' && !isEnabled(s)) return false;
if (status === 'disabled' && isEnabled(s)) return false;
if (page !== 'all' && !s.pages.some((p) => (surfaceFor(p)?.id || p) === page)) return false;
return true;
});
/* `isEnabled` closes over the preference arrays, so `disabledSkills` is the
real input rather than the function itself. */
}, [listed, search, status, page, disabledSkills]);
/**
* Flipping the switch has to move whichever of the two is holding the skill
* off, or the control does nothing visible.
*
* Enabling one that ships inactive writes an account definition with
* `status: active` — the same override the Skill Editor writes, through the
* same helper — rather than a second kind of "enabled" state beside it.
*
* One definition is switched, never a list: a UI skill and an Owliver skill
* are separate definitions with separate ids, so disabling either leaves the
* other exactly as it was.
*/
const toggleSkill = (id, enabled) => {
const next = enabled
? disabledSkills.filter((s) => s !== id)
: [...new Set([...disabledSkills, id])];
updatePreferences.mutate({ disabledSkills: next });
const skill = skills.find((s) => s.id === id);
const patch = {
disabledSkills: enabled
? disabledSkills.filter((s) => s !== id)
: [...new Set([...disabledSkills, id])],
};
if (skill && enabled && skill.status === 'inactive') {
const source = /^status:\s*\w+$/m.test(skill.markdown)
? skill.markdown.replace(/^status:\s*\w+$/m, 'status: active')
: skill.markdown.replace(/^---\n/, '---\nstatus: active\n');
patch.customSkills = upsertCustomSkill(customSkills, source).next;
}
updatePreferences.mutate(patch);
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
};
/**
* Remove, which means two different things honestly rather than one thing
* badly.
*
* An **account** definition is stored here, so removing it deletes it — the
* behaviour this button has always had. A **shipped** definition is a file in
* the repository this page cannot delete, so removing it takes it out of the
* workspace instead: hidden from the list, and switched off everywhere by the
* same `disabledSkills` every surface already reads. Reversible either way for
* shipped definitions, and the copy on the dialog says which it is doing.
*/
const removeSkill = (skill) => {
updatePreferences.mutate({ customSkills: removeCustomSkill(customSkills, skill.id) });
const patch = skill.custom
? { customSkills: removeCustomSkill(customSkills, skill.id) }
: {
removedSkills: [...new Set([...removedSkills, skill.id])],
disabledSkills: [...new Set([...disabledSkills, skill.id])],
};
updatePreferences.mutate(patch);
setPendingDelete(null);
toast.success(`${skill.name} removed`);
};
const activeCount = skills.filter((s) => !disabledSkills.includes(s.id)).length;
/**
* Puts a removed definition back on the list — switched off.
*
* Deliberately not re-enabled. `disabledSkills` now carries two intentions:
* "the reader switched this off" and "this was switched off because it was
* removed", and nothing distinguishes them. Clearing it on restore would
* silently turn on a skill the reader had disabled *before* removing it,
* which is the kind of thing that surfaces as a bug report weeks later.
*
* Coming back visibly inactive has no silent failure mode: the row is on the
* list with its switch off, and one click is the whole cost.
*/
const restoreSkill = (skill) => {
updatePreferences.mutate({
removedSkills: removedSkills.filter((id) => id !== skill.id),
});
toast.success(`${skill.name} restored — switched off`);
};
/** A row opens the editor its own kind is authored in. */
const editRoute = (skill) => (facet === 'owliver'
? `/admin/workspace/skills/owliver/${skill.id}`
: `/admin/workspace/skills/${skill.id}`);
const current = facetFor(facet);
const tabs = FACETS.map((f) => ({
value: f.id,
label: f.label,
icon: f.icon,
count: skillsWithFacet(present, f.id).length,
}));
/* Only the pages something is actually attached to, so the filter never
offers a page that would empty the list. */
const pageOptions = useMemo(() => {
const used = new Set(listed.flatMap((s) => s.pages.map((p) => surfaceFor(p)?.id || p)));
return PAGE_KEYS.filter((key) => used.has(key));
}, [listed]);
const activeCount = listed.filter(isEnabled).length;
return (
<AdminPage
title="Skills"
subtitle="Manage the capabilities Owliver can use across KROW."
subtitle="Manage the capabilities that extend KROW pages and Owliver."
actions={
<>
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
<ChevronLeft aria-hidden="true" /> Workspace
</Button>
<Button size="sm" shape="rounded" onClick={() => navigate('/admin/workspace/skills/new')}>
<Plus aria-hidden="true" /> Add skill
<Button size="sm" shape="rounded" onClick={() => navigate(current.route)}>
<Plus aria-hidden="true" /> {current.add}
</Button>
</>
}
>
<Tabs
tabs={tabs}
value={facet}
onChange={setFacet}
variant="underline"
size="sm"
layoutGroupId="skills-facet"
ariaLabel="Skill kinds"
/>
<p className="text-caption leading-relaxed text-ink-3">{current.blurb}</p>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="w-full lg:w-80">
<SearchInput value={search} onChange={setSearch} placeholder="Search skills" size="sm" />
<div className="w-full lg:w-72">
<SearchInput value={search} onChange={setSearch} placeholder={`Search ${current.label.toLowerCase()}`} size="sm" />
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9 w-36"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
{pageOptions.length > 1 && (
<Select value={page} onValueChange={setPage}>
<SelectTrigger className="h-9 w-44"><SelectValue placeholder="Page" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All pages</SelectItem>
{pageOptions.map((key) => (
<SelectItem key={key} value={key}>{surfaceFor(key)?.label || key}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<p className="text-caption text-ink-4 lg:ml-auto">
{skills.length} registered · {activeCount} active
{listed.length} registered · {activeCount} active
</p>
</div>
@@ -181,34 +432,109 @@ export default function WorkspaceSkills() {
<SkillRow
key={skill.id}
skill={skill}
enabled={!disabledSkills.includes(skill.id)}
facet={facet}
enabled={isEnabled(skill)}
onToggle={toggleSkill}
onEdit={(s) => navigate(`/admin/workspace/skills/${s.id}`)}
onEdit={(s) => navigate(editRoute(s))}
onDelete={setPendingDelete}
/>
))}
</Surface>
) : (
<EmptyState
title="No skills match that search"
description="Try a shorter term, or clear the search to see every registered skill."
action={<Button variant="outline" size="sm" onClick={() => setSearch('')}>Clear search</Button>}
title={listed.length ? 'No skills match those filters' : current.emptyTitle}
description={listed.length
? 'Try a shorter term, or clear the filters to see every skill on this list.'
: current.emptyBody}
action={listed.length ? (
<Button
variant="outline"
size="sm"
onClick={() => { setSearch(''); setStatus('all'); setPage('all'); }}
>
Clear filters
</Button>
) : (
<Button size="sm" onClick={() => navigate(current.route)}>
<Plus aria-hidden="true" /> {current.add}
</Button>
)}
/>
)}
{/* Removed definitions, and the way back.
A removal that cannot be undone from the same page it was made on is
a deletion wearing a softer word — so the list is here, collapsed
until there is something in it. */}
{removed.length > 0 && (
<section aria-labelledby="removed-skills" className="space-y-2">
<button
type="button"
onClick={() => setShowRemoved((v) => !v)}
aria-expanded={showRemoved}
className="inline-flex items-center gap-1.5 rounded text-caption font-medium text-ink-3 transition-colors
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<span id="removed-skills">
Removed from this workspace ({removed.length})
</span>
<ChevronDown
className={cn('h-3.5 w-3.5 transition-transform', showRemoved && 'rotate-180')}
aria-hidden="true"
/>
</button>
{showRemoved && (
<Surface
variant="solid"
radius="lg"
padding="none"
elevation="xs"
className="divide-y divide-border overflow-hidden"
>
{removed.map((skill) => (
<div key={skill.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-2">{skill.name}</p>
<p className="mt-0.5 text-[11px] text-ink-4">
{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'}
{' · '}
<span className="font-mono">{skill.path}</span>
</p>
</div>
<Button variant="outline" size="sm" shape="rounded" onClick={() => restoreSkill(skill)}>
Restore
</Button>
</div>
))}
</Surface>
)}
</section>
)}
<p className="text-caption leading-relaxed text-ink-4">
Each skill is a Markdown definition that attaches itself to the pages it names.
Definitions shipped with KROW live in the repository and can be switched off here;
skills added on this account are stored with your preferences. Markdown is stored as
data — it is never executed.
Each skill is a Markdown definition that attaches itself to the pages it names. A UI skill
draws a section on those pages; an Owliver skill answers questions on them. Both are read by
the same registry and the same data resolver, so a figure in a card and a figure in an answer
come from one reading. Definitions shipped with KROW live in the repository and can be
switched off here; skills added on this account are stored with your preferences. Markdown is
stored as data — it is never executed.
</p>
<ConfirmModal
open={Boolean(pendingDelete)}
onOpenChange={(open) => !open && setPendingDelete(null)}
title={`Remove ${pendingDelete?.name || 'skill'}?`}
description="This removes the definition from this account. Skills shipped with KROW are unaffected."
confirmLabel="Remove skill"
title={pendingDelete?.custom
? `Remove ${pendingDelete?.name || 'skill'}?`
: `Remove ${pendingDelete?.name || 'skill'} from this workspace?`}
description={pendingDelete?.custom
? `This deletes one definition from this account${
countSections(pendingDelete) > 0 && pendingDelete.facets?.includes('owliver')
? ' — including both the section it draws and the answers it offers'
: ''
}. Every other skill, and anything shipped with KROW, is unaffected.`
: `${pendingDelete?.name || 'This skill'} ships with KROW, so its definition stays in the repository. It is switched off everywhere and taken off this list — you can put it back from “Removed”.`}
confirmLabel={pendingDelete?.custom ? 'Remove skill' : 'Remove from workspace'}
tone="destructive"
onConfirm={() => pendingDelete && removeSkill(pendingDelete)}
/>

View File

@@ -9,6 +9,12 @@ prompt: Create a position
triggers:
- create a position
- create position
# A client is the company a position is staffed for, so asking for one starts
# the same conversation — it simply leads with the company question.
- create a client
- create client
- add a client
- new client
- create a * position
- create * position
- new position
@@ -50,6 +56,7 @@ Each line is `field | question | suggestions | required?`. Suggestions beginning
with `@` come from the application's own data, so a role category added in the
form is offered here without this file changing.
- company | Which client is this role for? Type the company name. | | required
- role_category | What role are you hiring for? | @roles | required
- location | Where will this role be based? | Chennai; Bengaluru; Coimbatore; Bay Area; Other | required
- pay | What is the pay range? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | required

View File

@@ -0,0 +1,57 @@
---
id: hiring-activity-assistant
name: Hiring Activity Assistant
description: Answer questions about recent hiring activity on a position.
pages:
- positions
status: active
triggers:
- hiring activity
- hiring summary
- hiring flow
- recent applications
- applications over time
owliver:
enabled: true
suggestions:
- Show hiring activity
- label: Summarize hiring activity
capability: summary
- label: Show hiring activity as a flow
capability: flow
capabilities:
- summary
- flow
responses:
summary:
title: Hiring Activity Summary
source: position.activity
periods:
- today
- yesterday
- last-week
flow:
title: Hiring Activity Flow
source: position.activity
steps:
- today
- yesterday
- last-week
---
# Hiring Activity Assistant
## Purpose
Answer questions about how many people have applied to a position lately, in the
panel beside the Positions experience.
This is a separate definition from the Hiring Activity UI skill, and each is
managed on its own list — but both name `position.activity`, so both are read by
the one shared resolver from the same application records. Switching either off
leaves the other exactly as it was.
## Capabilities
- Summarize applications to this position over today, yesterday and last week.
- Draw the same counts as a flow inside the answer.