update owliver skill
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
154
src/components/ai-assistant/PageContext.jsx
Normal file
154
src/components/ai-assistant/PageContext.jsx
Normal 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]
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
152
src/components/ai-assistant/history.js
Normal file
152
src/components/ai-assistant/history.js
Normal 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)}`;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
183
src/components/krow/Journey.jsx
Normal file
183
src/components/krow/Journey.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
401
src/components/skills/SkillSections.jsx
Normal file
401
src/components/skills/SkillSections.jsx
Normal 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,
|
||||
};
|
||||
181
src/components/skills/SkillSurface.jsx
Normal file
181
src/components/skills/SkillSurface.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user