update position page design
This commit is contained in:
@@ -37,7 +37,7 @@ export function ThinkingIndicator() {
|
||||
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
||||
* column on padding around them.
|
||||
*/
|
||||
export const Message = React.memo(({ role, text, blocks, streaming, stopped }) => {
|
||||
export const Message = React.memo(({ role, text, blocks, streaming, stopped, onPrompt }) => {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
@@ -57,7 +57,7 @@ export const Message = React.memo(({ role, text, blocks, streaming, stopped }) =
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
|
||||
</div>
|
||||
|
||||
<ResponseDocument blocks={blocks} streaming={streaming} />
|
||||
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
|
||||
|
||||
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,10 @@ import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import { useCreateJobPosting, usePreferences, useRoleCategories } from '@/lib/krowHooks';
|
||||
import {
|
||||
useAssignments, useAssignWorkers, useCreateJobPosting, useMarkInterviewReady,
|
||||
usePreferences, useRoleCategories,
|
||||
} from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
@@ -114,12 +117,67 @@ export default function KrowAssistant({
|
||||
return createJob.mutateAsync(result.data);
|
||||
}, [createJob]);
|
||||
|
||||
/**
|
||||
* The workforce picture the panel reasons over.
|
||||
*
|
||||
* The same collections the Positions page reads, so a recommendation and the
|
||||
* card behind it are looking at one moment. Assembled here rather than in the
|
||||
* routing layer because this is where the app's data already is; routing stays
|
||||
* pure and testable.
|
||||
*/
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
const workforce = React.useMemo(() => ({
|
||||
positions: facts.postings || [],
|
||||
currentPositionId: null,
|
||||
context: {
|
||||
profiles: facts.profiles || [],
|
||||
applications: facts.applications || [],
|
||||
assignments,
|
||||
courses: facts.forge?.library || [],
|
||||
staff: facts.staff || [],
|
||||
},
|
||||
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments]);
|
||||
|
||||
/**
|
||||
* The workforce write, through the mutation the app already has.
|
||||
*
|
||||
* `useAssignWorkers` creates the assignment, moves the application and logs
|
||||
* the activity — one path, reused rather than reimplemented. It only ever runs
|
||||
* on a turn the admin explicitly confirmed.
|
||||
*/
|
||||
const assignWorkers = useAssignWorkers();
|
||||
const onAssignWorkers = React.useCallback(async (plan) => {
|
||||
const created = await assignWorkers.mutateAsync({
|
||||
position: plan.position,
|
||||
workers: plan.workers,
|
||||
applications: facts.applications || [],
|
||||
});
|
||||
return created?.length ? created : null;
|
||||
}, [assignWorkers, facts.applications]);
|
||||
|
||||
/**
|
||||
* Move an assigned candidate to interview stage.
|
||||
*
|
||||
* The status transition the Position and Candidates pages already perform —
|
||||
* not a new interview record. `AIInterviewModal` writes the AIInterview when
|
||||
* the interview is actually conducted, and creating an empty one here would be
|
||||
* a second interview store claiming something that has not happened.
|
||||
*/
|
||||
const markInterviewReady = useMarkInterviewReady();
|
||||
const onScheduleInterview = React.useCallback(
|
||||
(plan) => markInterviewReady.mutateAsync({ application: plan.application, position: plan.position }),
|
||||
[markInterviewReady]
|
||||
);
|
||||
|
||||
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||
contextId: context.id,
|
||||
facts,
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
onCreatePosition: createPosition,
|
||||
onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce,
|
||||
disabledSkills,
|
||||
customSkills,
|
||||
roles,
|
||||
@@ -127,6 +185,13 @@ export default function KrowAssistant({
|
||||
courses: facts.forge?.library || [],
|
||||
});
|
||||
|
||||
/* A block inside an answer asking the next question, in place. Same entry
|
||||
point as the composer and the chips, so an in-panel action is an ordinary
|
||||
turn rather than a second way for the panel to change. */
|
||||
const askOwliver = React.useCallback((question) => {
|
||||
if (!busy) send({ question });
|
||||
}, [busy, send]);
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
const scrollRef = React.useRef(null);
|
||||
const isEmpty = messages.length === 0 && !pending;
|
||||
@@ -146,8 +211,8 @@ export default function KrowAssistant({
|
||||
const skillPrompts = skills
|
||||
.filter((s) => s.prompt)
|
||||
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
|
||||
return [...skillPrompts, ...buildPrompts(context.id, facts)];
|
||||
}, [followUp, skills, context.id, facts]);
|
||||
return [...skillPrompts, ...buildPrompts(context.id, facts, workforce)];
|
||||
}, [followUp, skills, context.id, facts, workforce]);
|
||||
|
||||
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
|
||||
at streaming frequency a smooth scroll never catches up and the thread
|
||||
@@ -281,6 +346,7 @@ export default function KrowAssistant({
|
||||
text={message.text}
|
||||
blocks={message.blocks}
|
||||
stopped={message.stopped}
|
||||
onPrompt={askOwliver}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight, Check, TriangleAlert } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowDownRight, ArrowUpRight, Check, ChevronRight, TriangleAlert } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
|
||||
@@ -269,20 +270,63 @@ const ListBlock = React.memo(({ block }) => {
|
||||
});
|
||||
ListBlock.displayName = 'ListBlock';
|
||||
|
||||
/** Findings, each in a tinted card so severity is scannable. */
|
||||
const InsightsBlock = React.memo(({ block }) => (
|
||||
/**
|
||||
* Findings, and — where the item carries one — a way to act on it.
|
||||
*
|
||||
* Two kinds of action, and the distinction is the point:
|
||||
*
|
||||
* `prompt` asks Owliver the next question, in place. The panel stays open,
|
||||
* the thread keeps its history, and the reader never loses the
|
||||
* position they were working on. This is the default for a
|
||||
* recommended person: inspecting a candidate is part of the
|
||||
* conversation, not a trip to another page.
|
||||
* `to` leaves for a route the app already has. Reserved for the explicit
|
||||
* "open the full record" case.
|
||||
*
|
||||
* `hint` is for an item that can do neither — it says why, instead of looking
|
||||
* clickable and doing nothing.
|
||||
*/
|
||||
const InsightsBlock = React.memo(({ block, onPrompt }) => (
|
||||
<div className="space-y-2">
|
||||
{block.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('rounded-xl border px-3 py-2.5', TONE_SURFACE[item.tone] || TONE_SURFACE.neutral)}
|
||||
>
|
||||
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||
{item.body && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{block.items.map((item, i) => {
|
||||
const surface = cn(
|
||||
'block w-full text-left rounded-xl border px-3 py-2.5',
|
||||
TONE_SURFACE[item.tone] || TONE_SURFACE.neutral
|
||||
);
|
||||
const interactive = cn(
|
||||
surface,
|
||||
'transition-colors hover:border-krow-blue/50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50'
|
||||
);
|
||||
const actionable = Boolean(item.prompt && onPrompt) || Boolean(item.to);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<p className="flex items-center gap-1 text-body-sm font-semibold text-ink-1">
|
||||
{item.title}
|
||||
{actionable && <ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden="true" />}
|
||||
</p>
|
||||
{item.body && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||
)}
|
||||
{item.hint && (
|
||||
<p className="mt-1 text-[11px] italic leading-relaxed text-ink-4">{item.hint}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (item.prompt && onPrompt) {
|
||||
return (
|
||||
<button key={i} type="button" onClick={() => onPrompt(item.prompt)} className={interactive}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (item.to) {
|
||||
return <Link key={i} to={item.to} className={interactive}>{body}</Link>;
|
||||
}
|
||||
return <div key={i} className={surface}>{body}</div>;
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
InsightsBlock.displayName = 'InsightsBlock';
|
||||
@@ -381,7 +425,7 @@ const RENDERERS = {
|
||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||
* else breathes.
|
||||
*/
|
||||
export const ResponseDocument = React.memo(({ blocks = [], streaming = false }) => (
|
||||
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt }) => (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, i) => {
|
||||
const Renderer = RENDERERS[block.type];
|
||||
@@ -391,7 +435,7 @@ export const ResponseDocument = React.memo(({ blocks = [], streaming = false })
|
||||
|
||||
return (
|
||||
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
|
||||
<Renderer block={block} />
|
||||
<Renderer block={block} onPrompt={onPrompt} />
|
||||
{/* The cursor trails the final block only while text is still arriving. */}
|
||||
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
|
||||
<span
|
||||
|
||||
@@ -47,6 +47,14 @@ export const list = (items, options = {}) =>
|
||||
items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options };
|
||||
|
||||
/** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */
|
||||
/**
|
||||
* Findings. `items: [{ title, body, tone?, to?, hint? }]`
|
||||
*
|
||||
* `to` makes the card a link into the app — a recommended person opening their
|
||||
* own record, rather than a name the reader has to go and look up. It is a route
|
||||
* the app already has; nothing here invents an address, and an item without one
|
||||
* renders exactly as it always did.
|
||||
*/
|
||||
export const insights = (items) =>
|
||||
items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { plural, verb } from './insights';
|
||||
import { prioritise, workforceStatusFor } from '@/lib/workforce';
|
||||
import { PERMISSIONS } from '@/pages/admin/permissions';
|
||||
|
||||
/**
|
||||
@@ -390,18 +391,71 @@ const PROMPTS = {
|
||||
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
|
||||
],
|
||||
|
||||
'admin.positions': (f) => [
|
||||
f.starvedPositions.length
|
||||
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
|
||||
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
|
||||
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
|
||||
{ label: 'Which position should I hire for first?', prompt: 'Which position should I hire for first?', capability: 'hiring-priority' },
|
||||
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' },
|
||||
{ label: 'Identify hiring bottlenecks', prompt: 'Identify hiring bottlenecks', capability: 'pipeline-health' },
|
||||
],
|
||||
/**
|
||||
* Positions suggestions are the current workforce situation, phrased as
|
||||
* questions.
|
||||
*
|
||||
* Each one is offered only when the data behind it exists — a prompt about
|
||||
* strong matches on a board with none is an invitation to a dead end. None of
|
||||
* them names a person: the question is asked generically and the candidates
|
||||
* are resolved live, so a suggestion cannot go stale between being rendered
|
||||
* and being clicked.
|
||||
*
|
||||
* Workforce prompts carry no `capability`, which is what sends them through
|
||||
* intent routing to the engine rather than to a page analytics responder.
|
||||
*/
|
||||
'admin.positions': (f, workforce) => {
|
||||
const out = [];
|
||||
|
||||
if (workforce?.positions?.length) {
|
||||
const { positions, context } = workforce;
|
||||
const ranked = prioritise(positions, context);
|
||||
const board = positions
|
||||
.filter((p) => p.status === 'active')
|
||||
.map((p) => ({ position: p, status: workforceStatusFor(p, context) }));
|
||||
|
||||
const withStrong = board.filter((r) => r.status.strong.length);
|
||||
const newToday = board.reduce((n, r) => n + r.status.newToday.length, 0);
|
||||
const readyCount = board.reduce(
|
||||
(n, r) => n + r.status.applicants.filter(
|
||||
(a) => ['ai_screened', 'shortlisted'].includes(a.application.status)
|
||||
).length,
|
||||
0
|
||||
);
|
||||
|
||||
if (withStrong.length) {
|
||||
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
|
||||
out.push({
|
||||
label: `${best.status.strong.length} strong for ${best.position.title}`,
|
||||
prompt: `Who matches ${best.position.title}?`,
|
||||
});
|
||||
}
|
||||
if (ranked.length) {
|
||||
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
|
||||
}
|
||||
if (newToday) {
|
||||
out.push({ label: `${newToday} applied today`, prompt: 'Who applied today?' });
|
||||
}
|
||||
if (readyCount) {
|
||||
out.push({ label: `${readyCount} ready for interview`, prompt: 'Who is ready for interview?' });
|
||||
}
|
||||
}
|
||||
|
||||
/* The page's own analytics questions keep their place behind the workforce
|
||||
ones — they are still the right answer to "how is hiring going". */
|
||||
out.push(
|
||||
f.starvedPositions.length
|
||||
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
|
||||
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
|
||||
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
|
||||
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
|
||||
);
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
'admin.candidatesList': (f) => [
|
||||
f.stalled.length
|
||||
@@ -548,6 +602,6 @@ export function buildIntro(contextId, facts, userName) {
|
||||
}
|
||||
|
||||
/** Suggested prompts for the current page and data. */
|
||||
export function buildPrompts(contextId, facts) {
|
||||
return PROMPTS[contextId]?.(facts) ?? [];
|
||||
export function buildPrompts(contextId, facts, workforce = null) {
|
||||
return PROMPTS[contextId]?.(facts, workforce) ?? [];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { doc, list, note, text } from './blocks';
|
||||
import { doc, insights, list, note, text } from './blocks';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { matchSkill } from '@/lib/skills/registry';
|
||||
import {
|
||||
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
|
||||
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
|
||||
namedAssignmentPreview,
|
||||
interviewPreview, interviewToExecute,
|
||||
namedAssignmentToExecute, positionPriority, resolveCandidate, resolvePosition,
|
||||
} from '@/lib/skills/workforceFlow';
|
||||
import {
|
||||
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
|
||||
extractSkillName, findCourseByName,
|
||||
@@ -168,6 +176,168 @@ export function outOfScopeAnswer(context) {
|
||||
* { kind: 'navigate', destination, doc } — other page; go there
|
||||
* { kind: 'outOfScope', doc } — nothing here can answer it
|
||||
*/
|
||||
/* ── Workforce ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The pages that carry enough context to answer a workforce question. */
|
||||
const WORKFORCE_CONTEXTS = new Set(['admin.positions']);
|
||||
|
||||
/**
|
||||
* A workforce question, resolved against the engine.
|
||||
*
|
||||
* Reading questions return their answer directly. The two that lead to a write
|
||||
* are shaped differently on purpose:
|
||||
*
|
||||
* `assign` builds a preview and returns it as a normal answer, with a
|
||||
* confirmation offered as a follow-up. Nothing is written.
|
||||
* `confirm_assign` re-derives the plan from live data and hands it back as
|
||||
* `{ kind: 'workforce', assign }` for `useAssistant` to
|
||||
* execute through the existing mutation — the same shape the
|
||||
* position-creation flow already uses.
|
||||
*
|
||||
* A question naming no position, on a page listing many, asks which rather than
|
||||
* choosing one.
|
||||
*/
|
||||
function resolveWorkforce(question, workforce) {
|
||||
const intent = matchWorkforceIntent(question);
|
||||
if (!intent) return null;
|
||||
|
||||
const { positions = [], context = {}, currentPositionId = null } = workforce;
|
||||
const position = resolvePosition(question, positions, currentPositionId);
|
||||
|
||||
/* Board-wide questions answer without a position. */
|
||||
if (intent === 'priority') {
|
||||
return { kind: 'answer-doc', ...positionPriority(positions, context) };
|
||||
}
|
||||
if (intent === 'applied_today') {
|
||||
return { kind: 'answer-doc', ...appliedToday(position, context, positions) };
|
||||
}
|
||||
if (intent === 'interview_ready') {
|
||||
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
|
||||
}
|
||||
|
||||
/* Everything below is about one role, and guessing which is not acceptable. */
|
||||
if (!position) return { kind: 'answer-doc', ...askWhichPosition(positions) };
|
||||
|
||||
if (intent === 'matches') return { kind: 'answer-doc', ...candidateMatches(position, context) };
|
||||
if (intent === 'availability') return { kind: 'answer-doc', ...availability(position, context) };
|
||||
|
||||
/**
|
||||
* Inspecting, explaining and opening a record are all about one person, so
|
||||
* they resolve the name first and say so plainly when they cannot.
|
||||
*/
|
||||
if (intent === 'candidate_detail' || intent === 'eligibility' || intent === 'open_profile') {
|
||||
const found = resolveCandidate(question, poolFor(position, context));
|
||||
|
||||
if (found.ambiguous) {
|
||||
return {
|
||||
kind: 'answer-doc',
|
||||
doc: doc(text('More than one person matches that name.'), list(found.ambiguous.map((r) => r.name))),
|
||||
followUp: found.ambiguous.slice(0, 4).map((r) => ({
|
||||
label: r.name, prompt: `Show ${r.name} for ${position.title}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (!found.row) {
|
||||
return { kind: 'answer-doc', ...candidateMatches(position, context) };
|
||||
}
|
||||
|
||||
/* The one case that deliberately leaves the panel — and only because the
|
||||
admin asked for the full record by name. */
|
||||
if (intent === 'open_profile') {
|
||||
const to = candidateRoute(found.row, context.applications || [], position);
|
||||
return {
|
||||
kind: 'answer-doc',
|
||||
doc: to
|
||||
? doc(
|
||||
text(`Opening the full record for **${found.row.name}**.`),
|
||||
insights([{ tone: 'info', title: `${found.row.name} — full profile`, body: 'Match analysis, reputation, experience, endorsements and activity.', to }])
|
||||
)
|
||||
: doc(
|
||||
text(`**${found.row.name}** has no application on file, so there is no candidate record to open.`),
|
||||
note('Assigning them creates one, and the full profile becomes available after that.')
|
||||
),
|
||||
followUp: [{ label: '← Back to candidate', prompt: `Show ${found.row.name} for ${position.title}` }],
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'answer-doc', ...candidateDetail(position, found.row, context) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A named person is a different request from "the best available": the admin
|
||||
* has already chosen, and the job is to validate that choice rather than to
|
||||
* rank. Both preview and confirmation check for a name first, so
|
||||
* "assign Maria" and "confirm assignment of Maria" stay one conversation.
|
||||
*/
|
||||
const named = (intent === 'assign' || intent === 'confirm_assign')
|
||||
? resolveCandidate(question, poolFor(position, context))
|
||||
: {};
|
||||
|
||||
if (named.ambiguous) {
|
||||
return {
|
||||
kind: 'answer-doc',
|
||||
doc: doc(
|
||||
text('More than one person matches that name.'),
|
||||
list(named.ambiguous.map((r) => r.name)),
|
||||
note('Give me the full name and I will check them against this role.')
|
||||
),
|
||||
followUp: named.ambiguous.slice(0, 4).map((r) => ({
|
||||
label: r.name,
|
||||
prompt: `Assign ${r.name} to ${position.title}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === 'assign') {
|
||||
return named.row
|
||||
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
|
||||
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Interview setup, for one named person against this role. Both steps resolve
|
||||
* the name the same way assignment does, so the conversation stays on the same
|
||||
* candidate without the admin repeating themselves.
|
||||
*/
|
||||
if (intent === 'interview_setup' || intent === 'confirm_interview') {
|
||||
const found = resolveCandidate(question, poolFor(position, context));
|
||||
if (!found.row) {
|
||||
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
|
||||
}
|
||||
|
||||
if (intent === 'interview_setup') {
|
||||
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
|
||||
}
|
||||
|
||||
const plan = interviewToExecute(position, found.row, context);
|
||||
if (!plan) {
|
||||
/* Re-checked at confirmation: if it can no longer proceed, say why rather
|
||||
than reporting a step that did not happen. */
|
||||
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
|
||||
}
|
||||
return { kind: 'workforce', interview: plan };
|
||||
}
|
||||
|
||||
if (intent === 'confirm_assign') {
|
||||
/* Re-derived from live data at the moment of confirmation, so the people
|
||||
written are the people who are still eligible. */
|
||||
const plan = named.row
|
||||
? namedAssignmentToExecute(position, named.row, context)
|
||||
: assignmentToExecute(position, context);
|
||||
|
||||
if (!plan) {
|
||||
/* The preview no longer holds — say so rather than writing something
|
||||
different from what was agreed to. */
|
||||
return named.row
|
||||
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
|
||||
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
|
||||
}
|
||||
return { kind: 'workforce', assign: plan };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Forge authoring ────────────────────────────────────────────────────── */
|
||||
|
||||
/** The five steps the Forge authoring flow actually walks through. */
|
||||
@@ -368,7 +538,7 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
|
||||
|
||||
export function resolveIntent({
|
||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||
courses = [],
|
||||
courses = [], workforce = null,
|
||||
}) {
|
||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||
|
||||
@@ -378,6 +548,18 @@ export function resolveIntent({
|
||||
});
|
||||
if (skill) return skill;
|
||||
|
||||
/**
|
||||
* 3. Workforce questions, on the pages that hold workforce context.
|
||||
*
|
||||
* Ahead of the page responder because these are the same words it answers
|
||||
* with analytics — "which position needs people" would otherwise return a
|
||||
* pipeline report rather than the ranked answer the engine can give.
|
||||
*/
|
||||
const workforceIntent = workforce && WORKFORCE_CONTEXTS.has(contextId)
|
||||
? resolveWorkforce(question, workforce)
|
||||
: null;
|
||||
if (workforceIntent) return workforceIntent;
|
||||
|
||||
/* The page you are on wins. Several subjects live on more than one page —
|
||||
departments are on both Analytics and Hired History — and moving someone
|
||||
off a page that can already answer them is worse than answering. Routing is
|
||||
|
||||
@@ -9,6 +9,9 @@ import { skillsForContext } from '@/lib/skills/registry';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import {
|
||||
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
|
||||
} from '@/lib/skills/workforceFlow';
|
||||
import { buildFacts } from './insights';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { resolveIntent } from './routing';
|
||||
@@ -74,14 +77,33 @@ export function useCurrentUserName() {
|
||||
*/
|
||||
async function streamDocument({ document, signal, onFrame, onDone }) {
|
||||
const snapshots = toSnapshots(document);
|
||||
const complete = snapshots[snapshots.length - 1] || [];
|
||||
|
||||
let latest = [];
|
||||
let interrupted = false;
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
if (signal?.aborted) break;
|
||||
if (signal?.aborted) { interrupted = true; break; }
|
||||
latest = snapshot;
|
||||
onFrame(snapshot);
|
||||
await new Promise((resolve) => setTimeout(resolve, 26));
|
||||
}
|
||||
if (latest.length) onDone(latest);
|
||||
|
||||
/**
|
||||
* An interrupted local document still settles complete.
|
||||
*
|
||||
* These documents are already fully computed before the first frame — the
|
||||
* reveal is presentation, not generation. So an interruption should not leave
|
||||
* a half-written sentence in the thread the way stopping a real generation
|
||||
* would: there is nothing partial about the answer, only about the animation.
|
||||
*
|
||||
* This matters most after a write. The mutation refreshes every query it
|
||||
* touched, the panel re-renders on the new data, and the reveal can be cut
|
||||
* short — leaving the confirmation of something that definitely happened
|
||||
* truncated mid-word.
|
||||
*/
|
||||
if (interrupted) onDone(complete);
|
||||
else if (latest.length) onDone(latest);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +119,8 @@ async function streamDocument({ document, signal, onFrame, onDone }) {
|
||||
* routing applies to both without either knowing it exists.
|
||||
*/
|
||||
export function useConversation({
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, disabledSkills = [], customSkills = [],
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
|
||||
workforce = null, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [],
|
||||
}) {
|
||||
const storageKey = `krow_assistant:${contextId}`;
|
||||
@@ -200,7 +223,8 @@ export function useConversation({
|
||||
intent = capability
|
||||
? { kind: 'answer' }
|
||||
: resolveIntent({
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
|
||||
courses, workforce,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,6 +259,51 @@ export function useConversation({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The workforce write, on the same terms as the one above: it happens
|
||||
* before the reply, because the reply states the new counts and those have
|
||||
* to be true when they are read.
|
||||
*
|
||||
* `intent.assign` only exists on a turn the admin explicitly confirmed, and
|
||||
* the plan inside it was re-derived from live data at that moment — so this
|
||||
* writes what was agreed to or reports that it could not.
|
||||
*/
|
||||
if (intent.kind === 'workforce' && intent.assign) {
|
||||
let done = null;
|
||||
try {
|
||||
done = await onAssignWorkers?.(intent.assign);
|
||||
} catch {
|
||||
done = null;
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'answer-doc',
|
||||
doc: done ? assignmentDone(intent.assign) : assignmentFailed(),
|
||||
followUp: done ? assignmentFollowUp(intent.assign) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The interview step. Same contract as the assignment above: it runs before
|
||||
* the reply, only on a confirmed turn, and through the app's existing
|
||||
* status transition rather than a second interview store.
|
||||
*/
|
||||
if (intent.kind === 'workforce' && intent.interview) {
|
||||
let moved = null;
|
||||
try {
|
||||
moved = await onScheduleInterview?.(intent.interview);
|
||||
} catch {
|
||||
moved = null;
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'answer-doc',
|
||||
doc: moved ? interviewDone(intent.interview) : interviewFailed(),
|
||||
};
|
||||
}
|
||||
|
||||
/* A skill that collects its input in the chat carries the state of that
|
||||
collection on every turn — including the turn that starts it. */
|
||||
if ('flow' in intent) setFlow(intent.flow);
|
||||
@@ -300,7 +369,9 @@ export function useConversation({
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, setFlow, disabledSkills,
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
Reference in New Issue
Block a user