- {/* Capped so the block stays a tight composition when expanded. */}
-
-
-
-
-
-
-
-
-
-
- {intro.title}
-
-
- {intro.greeting}
-
-
{intro.description}
-
-
- {composer}
-
-
+ {/* ── Body: the only region that scrolls ─────────────────────────── */}
+
+ {isEmpty ? (
+ /* Greeting and the single most relevant fact about this page. Top
+ aligned rather than centred: it is the first thing in a
+ conversation, not a splash screen, so it belongs where the first
+ message would be. */
+
+
+ {intro.title}
+
+
+ {intro.greeting}
+
+
+ {intro.description}
+
-
- ) : (
- /* ── Conversation ─────────────────────────────────────────────── */
- <>
-
+ ) : (
+
{messages.map((message, i) => (
{i > 0 && message.role === 'user' && }
@@ -193,25 +176,28 @@ export default function KrowAssistant({
{error && {error}}
+ )}
+
-
- {/* Capped in conversation: the vertical space belongs to the thread.
- Expanded fits more per line, so it can afford one more. */}
- {!busy && (
-
- )}
- {composer}
-
- Krow AI reads this page's data. Check anything you act on.
-
-
- >
- )}
+ {/* ── Composer: fixed to the bottom in both states ───────────────── */}
+
+ {/* All suggestions on the landing screen, where they teach what can be
+ asked. Capped once a thread exists, because from then on the vertical
+ space belongs to the conversation. Expanded fits more per line, so it
+ can afford one more. */}
+ {!busy && (
+
+ )}
+ {composer}
+
+ Owliver reads this page's data. Check anything you act on.
+
+
);
}
diff --git a/src/components/ai-assistant/PromptInput.jsx b/src/components/ai-assistant/PromptInput.jsx
index 3f006a3..7c84ee4 100644
--- a/src/components/ai-assistant/PromptInput.jsx
+++ b/src/components/ai-assistant/PromptInput.jsx
@@ -10,8 +10,12 @@ import { cn } from '@/lib/utils';
* deliberate newline. That combination is what makes typing a real question feel
* natural instead of cramped.
*
- * `placeholders` rotate while the field is empty and unfocused, which teaches
- * the range of what can be asked without spending vertical space on a legend.
+ * `placeholders` is a list, but only the first is shown. It used to rotate every
+ * few seconds; that was defensible when the panel opened on demand and wrong once
+ * Owliver became permanent furniture, because a permanently visible field that
+ * rewrites itself forever is movement in the corner of the eye with nothing to
+ * gain — the suggestion chips already teach the range of what can be asked, and
+ * they do it without a timer or a rerender every four seconds.
*/
export function PromptInput({
value,
@@ -26,7 +30,6 @@ export function PromptInput({
}) {
const textareaRef = React.useRef(null);
const [focused, setFocused] = React.useState(false);
- const [placeholderIndex, setPlaceholderIndex] = React.useState(0);
/* Grow to fit the content, up to a cap, then scroll internally. */
React.useEffect(() => {
@@ -36,30 +39,6 @@ export function PromptInput({
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
}, [value]);
- /* Rotate the examples only while the field is idle — changing the placeholder
- under someone who is mid-thought is distracting. The crossfade is done with
- a real element rather than the native placeholder, which cannot animate. */
- const [fading, setFading] = React.useState(false);
-
- React.useEffect(() => {
- if (value || focused || placeholders.length < 2) return undefined;
- const timer = setInterval(() => {
- setFading(true);
- // Swap the text at the midpoint of the fade so neither string is visible
- // while it changes.
- setTimeout(() => {
- setPlaceholderIndex((i) => (i + 1) % placeholders.length);
- setFading(false);
- }, 220);
- }, 4000);
- return () => clearInterval(timer);
- }, [value, focused, placeholders.length]);
-
- /* A shorter list on a context change must not leave the index out of range. */
- React.useEffect(() => {
- setPlaceholderIndex(0);
- }, [placeholders]);
-
React.useEffect(() => {
if (autoFocus) textareaRef.current?.focus();
}, [autoFocus]);
@@ -100,7 +79,7 @@ export function PromptInput({
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={busy}
- aria-label="Ask Krow AI"
+ aria-label="Ask Owliver"
aria-busy={busy || undefined}
className={cn(
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
@@ -110,19 +89,19 @@ export function PromptInput({
)}
/>
- {/* Animated placeholder. Hidden from assistive tech — the textarea's
- aria-label is the accessible name. */}
+ {/* Rendered as an element rather than the native `placeholder` so it can
+ be truncated and swapped for the busy state. Hidden from assistive
+ tech — the textarea's aria-label is the accessible name. */}
{!value && (
- {busy ? 'Thinking…' : placeholders[placeholderIndex]}
+ {busy ? 'Thinking…' : placeholders[0]}
)}
diff --git a/src/components/ai-assistant/ResizeDivider.jsx b/src/components/ai-assistant/ResizeDivider.jsx
new file mode 100644
index 0000000..08a4c9d
--- /dev/null
+++ b/src/components/ai-assistant/ResizeDivider.jsx
@@ -0,0 +1,110 @@
+import * as React from 'react';
+import { GripVertical } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+/**
+ * ResizeDivider — the drag handle between the dashboard and the Owliver panel.
+ *
+ * The hit area is deliberately wider than the visible line: a 1px target is
+ * unusable, so the divider occupies 12px and shows a 2px rule inside it. The
+ * grip dots only appear on hover, which keeps a permanent chrome element from
+ * competing with the content on either side.
+ *
+ * Keyboard support is not a nicety here — a pointer-only resize is unreachable
+ * for anyone who cannot drag. Arrow keys nudge by 16px, Home/End jump to the
+ * limits, and the whole thing is exposed as a `separator` with live values.
+ */
+export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) {
+ const [dragging, setDragging] = React.useState(false);
+
+ /* Pointer events on the window rather than the element: once a drag starts the
+ pointer routinely leaves the 12px handle, and a listener bound to the handle
+ would drop the drag the moment it did. */
+ React.useEffect(() => {
+ if (!dragging) return undefined;
+
+ const onMove = (e) => {
+ // The panel is right-aligned, so distance from the right edge is the width.
+ onResize(Math.round(window.innerWidth - e.clientX));
+ };
+ const stop = () => setDragging(false);
+
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', stop);
+ window.addEventListener('pointercancel', stop);
+
+ // Suppress text selection and swap the cursor for the whole document, so the
+ // cursor does not flicker back whenever the pointer crosses other elements.
+ const previousCursor = document.body.style.cursor;
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+
+ return () => {
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', stop);
+ window.removeEventListener('pointercancel', stop);
+ document.body.style.cursor = previousCursor;
+ document.body.style.userSelect = '';
+ };
+ }, [dragging, onResize]);
+
+ const onKeyDown = (e) => {
+ const step = e.shiftKey ? 48 : 16;
+ const actions = {
+ ArrowLeft: () => onResize(width + step), // widening the panel moves the divider left
+ ArrowRight: () => onResize(width - step),
+ Home: () => onResize(max),
+ End: () => onResize(min),
+ };
+ const action = actions[e.key];
+ if (!action) return;
+ e.preventDefault();
+ action();
+ };
+
+ return (
+
{
+ e.preventDefault();
+ setDragging(true);
+ }}
+ onDoubleClick={onDoubleClick}
+ onKeyDown={onKeyDown}
+ className={cn(
+ 'group relative flex w-3 shrink-0 cursor-col-resize touch-none items-center justify-center',
+ 'focus-visible:outline-none',
+ className
+ )}
+ >
+ {/* The visible rule. Brand-tinted while dragging or focused so the
+ interaction has feedback without the handle being loud at rest. */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ai-assistant/capabilities/admin.js b/src/components/ai-assistant/capabilities/admin.js
index 09f3963..528f595 100644
--- a/src/components/ai-assistant/capabilities/admin.js
+++ b/src/components/ai-assistant/capabilities/admin.js
@@ -1,5 +1,11 @@
-import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
-import { actions, answer, doc, heading, insights, kpis, note, status, table, text } from '../blocks';
+import {
+ Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch,
+ Lightbulb, ScrollText, ShieldAlert, TrendingUp, Users,
+} from 'lucide-react';
+import {
+ actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
+ text, timeline,
+} from '../blocks';
import { plural, verb } from '../insights';
/**
@@ -68,6 +74,199 @@ const platformHealth = (f) => {
);
};
+/**
+ * Workforce summary — the platform's scale in one read.
+ *
+ * Answers "how big is this thing and what is it made of", which is a different
+ * question from "is it healthy": no checks, no verdicts, just the composition.
+ */
+const workforceSummary = (f) => doc(
+ heading('Workforce summary', `${plural(f.postings.length, 'position')} · ${plural(f.profiles.length, 'worker')}`),
+ kpis([
+ { label: 'Open roles', value: f.openPositions.length, sub: `of ${f.postings.length}` },
+ { label: 'Applicants', value: f.total, sub: `${f.scored.length} scored` },
+ { label: 'On staff', value: f.staff.length, sub: `${f.hired.length} hired here` },
+ { label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` },
+ ]),
+ heading('Where the people are'),
+ meters([
+ { label: 'In the pipeline', value: f.total, max: f.total + f.profiles.length },
+ { label: 'In the talent pool', value: f.profiles.length, max: f.total + f.profiles.length },
+ { label: 'Placed on staff', value: f.staff.length, max: f.total + f.profiles.length, tone: 'success' },
+ ]),
+ heading('Demand by role category'),
+ table(
+ [
+ { key: 'category', label: 'Category' },
+ { key: 'roles', label: 'Roles', align: 'right' },
+ { key: 'applied', label: 'Applicants', align: 'right' },
+ ],
+ Object.values(f.byRole.reduce((acc, r) => {
+ acc[r.category] ||= { category: r.category, roles: 0, applied: 0 };
+ acc[r.category].roles += 1;
+ acc[r.category].applied += r.applied;
+ return acc;
+ }, {})).sort((a, b) => b.applied - a.applied)
+ ),
+ text(
+ f.profiles.length > f.total
+ ? `The talent pool is larger than the live pipeline — ${f.profiles.length} registered workers against ${f.total} applications. Most of the supply is not applying to anything.`
+ : 'Most of the platform\'s people are attached to a live application rather than sitting in the pool.'
+ )
+);
+
+/**
+ * Hiring operations — throughput and speed.
+ *
+ * The operational question is whether work is moving, so this leads with rate
+ * and duration rather than totals.
+ */
+const hiringOperations = (f) => {
+ const perRole = f.byRole
+ .slice()
+ .sort((a, b) => b.applied - a.applied)
+ .map((r) => ({
+ title: r.title,
+ applied: r.applied,
+ qualified: r.qualified,
+ hired: r.hired || '—',
+ rate: { value: `${r.applied ? Math.round((r.qualified / r.applied) * 100) : 0}%`, tone: r.applied && r.qualified / r.applied >= 0.5 ? 'success' : 'warning' },
+ }));
+
+ return doc(
+ heading('Hiring operations'),
+ kpis([
+ { label: 'Applicants', value: f.total },
+ { label: 'Hires', value: f.hired.length, tone: 'success', sub: `${f.hireRate}% of applicants` },
+ { label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
+ { label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 80 ? 'success' : 'warning', sub: 'coverage' },
+ ]),
+ heading('Throughput by role'),
+ table(
+ [
+ { key: 'title', label: 'Role' },
+ { key: 'applied', label: 'Applied', align: 'right' },
+ { key: 'qualified', label: '70+', align: 'right' },
+ { key: 'hired', label: 'Hired', align: 'right' },
+ { key: 'rate', label: 'Qualified', align: 'right' },
+ ],
+ perRole
+ ),
+ insights([
+ f.starvedPositions.length && {
+ tone: 'warning',
+ title: `${plural(f.starvedPositions.length, 'role')} attracting nobody`,
+ body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Throughput cannot improve on a role with no input.`,
+ },
+ f.timeToHire > 0 && {
+ tone: f.timeToHire <= 7 ? 'success' : 'warning',
+ title: `Hires close in ${plural(f.timeToHire, 'day')}`,
+ body: f.timeToHire <= 7
+ ? 'Fast enough that strong candidates are unlikely to be lost to a competing offer.'
+ : 'Long enough that the best candidates may accept elsewhere first.',
+ },
+ ]),
+ note('Time-to-hire is measured from application to decision, so it reflects this platform\'s part of the process only.')
+ );
+};
+
+/**
+ * Pipeline health — the funnel and its worst step.
+ *
+ * A funnel block rather than a table: the shape is the finding, and the single
+ * weakest transition is what someone can actually act on.
+ */
+const pipelineHealth = (f) => {
+ if (!f.total) return answer('Nothing has entered the pipeline yet, so there is no funnel to read.');
+
+ const worst = f.bottleneck;
+
+ return doc(
+ heading('Pipeline health', `${plural(f.total, 'candidate')} entered`),
+ funnel(f.funnel.map((stage, i) => ({
+ label: stage.label,
+ count: stage.count,
+ rate: i === 0 ? null : f.transitions[i - 1]?.rate,
+ lost: i === 0 ? 0 : f.transitions[i - 1]?.lost,
+ }))),
+ worst && worst.lost > 0
+ ? insights([{
+ tone: worst.rate < 50 ? 'risk' : 'warning',
+ title: `${worst.from} → ${worst.to} is the bottleneck`,
+ body: `Only ${worst.rate}% pass through, losing ${plural(worst.lost, 'candidate')}. Every other step performs better, so this is where recovered conversion is cheapest.`,
+ }])
+ : insights([{
+ tone: 'success',
+ title: 'No single step is losing candidates',
+ body: 'Conversion is even across the funnel — there is no cheap win to recover here.',
+ }]),
+ heading('Stage-to-stage rates'),
+ table(
+ [
+ { key: 'step', label: 'Transition' },
+ { key: 'rate', label: 'Pass', align: 'right' },
+ { key: 'lost', label: 'Lost', align: 'right' },
+ ],
+ f.transitions.map((t) => ({
+ step: `${t.from} → ${t.to}`,
+ rate: { value: `${t.rate}%`, tone: t.rate < 60 ? 'warning' : 'success' },
+ lost: t.lost ? { value: `−${t.lost}`, tone: 'risk' } : '0',
+ }))
+ )
+ );
+};
+
+/**
+ * Attention required — the triage queue, ordered by cost of inaction.
+ *
+ * The same items the Control Center lists, because the panel must never rank
+ * things differently from the page beside it.
+ */
+const attentionRequired = (f) => {
+ const items = [
+ f.starvedPositions.length && {
+ tone: 'risk',
+ title: `${plural(f.starvedPositions.length, 'role')} with no candidate supply`,
+ body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Live and attracting nobody — usually a pay band or distribution problem.`,
+ },
+ f.stalled.length && {
+ tone: 'risk',
+ title: `${plural(f.stalled.length, 'strong candidate')} not actioned`,
+ body: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened. The most expensive kind of delay.`,
+ },
+ f.unscreened.length && {
+ tone: 'warning',
+ title: `${plural(f.unscreened.length, 'applicant')} waiting on screening`,
+ body: 'Each unscored application is a decision made without a standard.',
+ },
+ f.unscoredProfiles.length && {
+ tone: 'warning',
+ title: `${plural(f.unscoredProfiles.length, 'talent profile')} without a score`,
+ body: 'Invisible to matching, so employers cannot find them even when they fit.',
+ },
+ f.unratedStaff.length && {
+ tone: 'info',
+ title: `${plural(f.unratedStaff.length, 'hire')} awaiting review`,
+ body: 'Employer ratings are the only verified performance signal feeding scores.',
+ },
+ f.flagged.length && {
+ tone: 'warning',
+ title: `${plural(f.flagged.length, 'interview')} flagged for integrity`,
+ body: 'Worth clearing before those candidates progress.',
+ },
+ ].filter(Boolean);
+
+ if (!items.length) {
+ return answer('Nothing needs intervention. Every open role has candidates, every applicant is scored, and no interview is flagged.');
+ }
+
+ return doc(
+ heading(`${plural(items.length, 'item')} need attention`, 'Ordered by cost of leaving it'),
+ insights(items),
+ text(`Start with ${items[0].title.toLowerCase()} — it is the one where waiting costs the most.`)
+ );
+};
+
const recommendations = (f) => {
const items = [
f.unscoredProfiles.length && {
@@ -197,6 +396,195 @@ const candidateRiskAnalysis = (f) => {
);
};
+/**
+ * Screening gaps — where the record is incomplete.
+ *
+ * Distinct from risk: risk is what the data says about a candidate, this is what
+ * the data fails to say at all. An unscored applicant is not a weak applicant,
+ * and conflating the two is how good people get passed over.
+ */
+const screeningGaps = (f) => {
+ const gaps = [
+ {
+ label: 'Unscored applications',
+ count: f.unscreened.length,
+ note: 'Cannot be ranked or compared against anyone.',
+ },
+ {
+ label: 'Scored without credentials',
+ count: f.missingCredentials.length,
+ note: 'No certifications on file to verify against a role requirement.',
+ },
+ {
+ label: 'No recorded experience',
+ count: f.scored.filter((a) => !a.years_experience).length,
+ note: 'Scored on other dimensions, unverifiable on history.',
+ },
+ {
+ label: 'Interviews started, not scored',
+ count: f.interviews.length - f.completedInterviews.length,
+ note: 'Abandoned before a verdict, so the interview signal is missing.',
+ },
+ {
+ label: 'Availability not set',
+ count: f.narrowAvailability.length,
+ note: 'One slot or none recorded, so scheduling is guesswork.',
+ },
+ ].filter((g) => g.count > 0);
+
+ if (!gaps.length) {
+ return answer('No screening gaps. Every applicant is scored, credentialed and has availability recorded, and every interview reached a verdict.');
+ }
+
+ return doc(
+ heading('Screening gaps', `${plural(gaps.length, 'kind')} of missing data`),
+ table(
+ [
+ { key: 'label', label: 'Gap' },
+ { key: 'count', label: 'Records', align: 'right' },
+ ],
+ gaps.map((g) => ({ label: g.label, count: { value: g.count, tone: 'warning' } }))
+ ),
+ heading('Coverage'),
+ status([
+ {
+ label: 'Screening coverage',
+ value: `${f.standardizedPct}%`,
+ ok: f.standardizedPct >= 80,
+ note: f.standardizedPct >= 80
+ ? 'Enough of the pool is scored for comparisons to mean something.'
+ : `${plural(f.unscreened.length, 'applicant')} outside the standard.`,
+ },
+ {
+ label: 'Interview completion',
+ value: `${f.interviewCompletion}%`,
+ ok: f.interviewCompletion >= 80,
+ note: f.interviewCompletion >= 80
+ ? 'Most interviews reach a verdict.'
+ : 'A meaningful share of interviews stop before scoring.',
+ },
+ ]),
+ list(gaps.map((g) => `**${g.label}** — ${g.note}`)),
+ note('A gap is missing evidence, not a negative finding. These candidates are unassessed, not unsuitable.')
+ );
+};
+
+/**
+ * Hiring recommendations — who to move on, and in what order.
+ *
+ * Named people with a stated reason. A recommendation that does not name anyone
+ * is an observation, and the Admin already has plenty of those.
+ */
+const hiringRecommendations = (f) => {
+ if (!f.scored.length) {
+ return answer('Nothing is scored yet, so there is no basis for a recommendation. Screen the pipeline first.');
+ }
+
+ const advance = f.ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened').slice(0, 4);
+ const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && a.status === 'ai_screened').slice(0, 4);
+ const review = f.unscreened.slice(0, 4);
+
+ const rows = [
+ ...advance.map((a) => ({
+ name: a.applicant_name,
+ role: a.job_title,
+ score: { value: a.ai_score, tone: 'success' },
+ action: { badge: 'Advance', tone: 'success' },
+ })),
+ ...interview.map((a) => ({
+ name: a.applicant_name,
+ role: a.job_title,
+ score: { value: a.ai_score, tone: 'info' },
+ action: { badge: 'Interview', tone: 'info' },
+ })),
+ ...review.map((a) => ({
+ name: a.applicant_name,
+ role: a.job_title,
+ score: '—',
+ action: { badge: 'Screen', tone: 'warning' },
+ })),
+ ];
+
+ return doc(
+ heading('Recommended next actions', `${plural(rows.length, 'candidate')}`),
+ table(
+ [
+ { key: 'name', label: 'Candidate' },
+ { key: 'role', label: 'Role' },
+ { key: 'score', label: 'Score', align: 'right' },
+ { key: 'action', label: 'Action', align: 'right' },
+ ],
+ rows
+ ),
+ actions([
+ advance.length && {
+ title: `Advance ${plural(advance.length, 'candidate')} scoring 80 or above`,
+ body: `${advance.map((a) => a.applicant_name).join(', ')}. Already screened and waiting — the delay, not the assessment, is what loses these people.`,
+ },
+ review.length && {
+ title: `Screen ${plural(review.length, 'waiting applicant')}`,
+ body: 'Until they are scored they cannot be compared with anyone already in the funnel.',
+ },
+ f.singleCandidateRoles.length && {
+ title: `Widen supply on ${plural(f.singleCandidateRoles.length, 'role')}`,
+ body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. One withdrawal reopens the search.`,
+ },
+ ]),
+ note('Ranked on screening data only. Nothing here accounts for a conversation you have had that the platform has not seen.')
+ );
+};
+
+/**
+ * Top candidates side by side.
+ *
+ * A comparison is a table — the whole point is reading the same dimension across
+ * people, which prose makes impossible.
+ */
+const topCandidates = (f) => {
+ const top = f.ranked.slice(0, 5);
+ if (!top.length) return answer('No scored candidates yet, so there is nobody to compare.');
+
+ const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
+
+ return doc(
+ heading('Strongest candidates', `Top ${top.length} by KROW score`),
+ table(
+ [
+ { key: 'name', label: 'Candidate' },
+ { key: 'role', label: 'Applied for' },
+ { key: 'score', label: 'Score', align: 'right' },
+ { key: 'stage', label: 'Stage', align: 'right' },
+ ],
+ top.map((a) => ({
+ name: a.applicant_name,
+ role: a.job_title,
+ score: { value: a.ai_score, tone: a.ai_score >= 80 ? 'success' : 'info' },
+ stage: { badge: String(a.status).replace(/_/g, ' '), tone: a.status === 'hired' ? 'success' : 'neutral' },
+ }))
+ ),
+ heading('Score breakdown', top[0].applicant_name),
+ meters(DIMENSIONS.map((d) => ({
+ label: d[0].toUpperCase() + d.slice(1),
+ value: top[0].score_breakdown?.[d] ?? 0,
+ max: 100,
+ }))),
+ insights([
+ top.length >= 2 && {
+ tone: 'info',
+ title: `${top[0].applicant_name} leads by ${top[0].ai_score - top[1].ai_score} points`,
+ body: top[0].ai_score - top[1].ai_score <= 5
+ ? `Effectively level with ${top[1].applicant_name} — close enough that the choice should come down to a conversation, not the score.`
+ : `A clear enough margin over ${top[1].applicant_name} to act on.`,
+ },
+ f.stalled.length && {
+ tone: 'warning',
+ title: `${plural(f.stalled.length, 'candidate')} at 80+ waiting on a decision`,
+ body: f.stalled.map((a) => a.applicant_name).join(', '),
+ },
+ ])
+ );
+};
+
/* ── Activity ───────────────────────────────────────────────────────────── */
const auditSummary = (f) => {
@@ -294,26 +682,231 @@ const userActivityInsights = (f) => {
);
};
+/**
+ * Privileged actions carry more audit weight than reads. Kept in step with the
+ * Activity page's own severity table — one definition of "privileged", so the
+ * panel and the log can never disagree about what matters.
+ */
+const HIGH_SEVERITY = ['hire_candidate', 'create_position'];
+const MEDIUM_SEVERITY = ['screen_candidate', 'start_interview'];
+
+/**
+ * Unusual activity — what is out of pattern, stated as a pattern.
+ *
+ * Deliberately not "suspicious". These are deviations from the platform's own
+ * baseline, and on a real deployment most of them turn out to be an integration
+ * or a busy afternoon. Saying otherwise would manufacture alarm.
+ */
+const unusualActivity = (f) => {
+ if (!f.activity.length) return answer('No activity logged yet, so there is no baseline to deviate from.');
+
+ const perUser = f.activity.reduce((acc, e) => {
+ acc[e.user_email] ||= { name: e.user_name, count: 0, privileged: 0, types: new Set() };
+ acc[e.user_email].count += 1;
+ acc[e.user_email].types.add(e.event_type);
+ if (HIGH_SEVERITY.includes(e.event_type)) acc[e.user_email].privileged += 1;
+ return acc;
+ }, {});
+
+ const accounts = Object.entries(perUser);
+ const busiest = accounts.slice().sort((a, b) => b[1].count - a[1].count)[0];
+ const share = busiest ? Math.round((busiest[1].count / f.activity.length) * 100) : 0;
+
+ /* Bursts: more than three events from one account inside an hour. */
+ const byHour = f.activity.reduce((acc, e) => {
+ const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
+ acc[key] = (acc[key] || 0) + 1;
+ return acc;
+ }, {});
+ const bursts = Object.entries(byHour).filter(([, n]) => n > 3);
+
+ const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type));
+ const offHours = f.activity.filter((e) => {
+ const hour = new Date(e.created_date).getHours();
+ return hour < 6 || hour >= 22;
+ });
+
+ const patterns = [
+ share >= 50 && {
+ tone: 'warning',
+ title: `One account is ${share}% of all activity`,
+ body: `${busiest[1].name || busiest[0]} accounts for ${busiest[1].count} of ${f.activity.length} events. Worth confirming that is a person and not an integration running under someone's credentials.`,
+ },
+ bursts.length && {
+ tone: 'warning',
+ title: `${plural(bursts.length, 'burst')} of rapid activity`,
+ body: 'More than three actions from one account inside a single hour. Normal for batch work, worth a glance if it is not expected.',
+ },
+ offHours.length && {
+ tone: 'info',
+ title: `${plural(offHours.length, 'event')} outside working hours`,
+ body: 'Logged before 06:00 or after 22:00 local time. Not a problem on its own; a pattern of privileged actions at those hours would be.',
+ },
+ f.activity24h.length === 0 && {
+ tone: 'warning',
+ title: 'Nothing in the last 24 hours',
+ body: 'Expected for a seeded dataset. On a live platform, silence is an ingestion question rather than a quiet day.',
+ },
+ privileged.length > f.activity.length * 0.3 && {
+ tone: 'warning',
+ title: `Privileged actions are ${Math.round((privileged.length / f.activity.length) * 100)}% of the log`,
+ body: 'A log dominated by hires and position changes usually means routine reads are not being captured, which weakens the audit trail.',
+ },
+ ].filter(Boolean);
+
+ if (!patterns.length) {
+ return doc(
+ heading('Nothing out of pattern'),
+ kpis([
+ { label: 'Events', value: f.activity.length },
+ { label: 'Accounts', value: accounts.length },
+ { label: 'Busiest share', value: `${share}%`, tone: 'success' },
+ ]),
+ text('Activity is spread across accounts, there are no bursts, and privileged actions are a normal share of the log.')
+ );
+ }
+
+ return doc(
+ heading(`${plural(patterns.length, 'pattern')} worth attention`),
+ insights(patterns),
+ heading('Per account'),
+ table(
+ [
+ { key: 'name', label: 'Account' },
+ { key: 'events', label: 'Events', align: 'right' },
+ { key: 'privileged', label: 'Privileged', align: 'right' },
+ ],
+ accounts
+ .sort((a, b) => b[1].count - a[1].count)
+ .slice(0, 6)
+ .map(([email, d]) => ({
+ name: d.name || email,
+ events: { value: d.count, tone: d.count === busiest[1].count ? 'info' : undefined },
+ privileged: d.privileged ? { value: d.privileged, tone: 'warning' } : '0',
+ }))
+ ),
+ note('Deviations from this platform\'s own baseline, not accusations. Each one is a question to ask, and most have a dull answer.')
+ );
+};
+
+/**
+ * Security insights — the audit trail judged as a control, not as a feed.
+ *
+ * The question a security reviewer actually asks is whether the log would stand
+ * up: are privileged actions attributable, is coverage complete, is anything
+ * unaccounted for. So this reports on the trail itself.
+ */
+const securityInsights = (f) => {
+ const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type));
+ const attributed = privileged.filter((e) => e.user_email && e.user_name);
+ const named = f.activity.filter((e) => e.user_email);
+ const hires = f.activity.filter((e) => e.event_type === 'hire_candidate').length;
+
+ const checks = [
+ {
+ label: 'Privileged actions attributable',
+ value: `${attributed.length}/${privileged.length}`,
+ ok: privileged.length === attributed.length,
+ note: privileged.length === attributed.length
+ ? 'Every hire and position change traces to a named account.'
+ : `${privileged.length - attributed.length} cannot be traced to a named person.`,
+ },
+ {
+ label: 'Events carry an actor',
+ value: `${named.length}/${f.activity.length}`,
+ ok: named.length === f.activity.length,
+ note: named.length === f.activity.length
+ ? 'No anonymous entries in the log.'
+ : `${f.activity.length - named.length} entries have no account attached.`,
+ },
+ {
+ label: 'Hires reconcile with staff',
+ value: `${hires}/${f.staff.length}`,
+ ok: hires === f.staff.length,
+ note: hires === f.staff.length
+ ? 'Every person on staff has a matching hire event.'
+ : `${Math.abs(f.staff.length - hires)} on staff without a corresponding hire event, or the reverse.`,
+ },
+ {
+ label: 'Recent coverage',
+ value: `${f.activity24h.length} in 24h`,
+ ok: f.activity24h.length > 0,
+ note: f.activity24h.length
+ ? 'The log is being written to.'
+ : 'No events in the last day — on a live platform, check ingestion before concluding it was quiet.',
+ },
+ {
+ label: 'Interview integrity',
+ value: `${f.interviews.length - f.flagged.length}/${f.interviews.length}`,
+ ok: f.flagged.length === 0,
+ note: f.flagged.length
+ ? `${plural(f.flagged.length, 'interview')} below full integrity — usually implausibly fast responses.`
+ : 'No interview flagged for integrity.',
+ },
+ ];
+
+ const passing = checks.filter((c) => c.ok).length;
+
+ return doc(
+ heading(`Security posture: ${passing}/${checks.length} checks passing`),
+ status(checks),
+ heading('Privileged action log', `${plural(privileged.length, 'event')}`),
+ privileged.length
+ ? timeline(
+ privileged.slice(0, 6).map((e) => ({
+ title: String(e.event_type).replace(/_/g, ' '),
+ description: `${e.user_name || e.user_email}${e.details ? ` — ${e.details}` : ''}`,
+ timestamp: new Date(e.created_date).toLocaleString(undefined, {
+ month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
+ }),
+ tone: MEDIUM_SEVERITY.includes(e.event_type) ? 'warning' : 'risk',
+ }))
+ )
+ : text('No privileged actions in this log.'),
+ text(
+ passing === checks.length
+ ? 'The trail would hold up to review: every privileged action is attributable and the records reconcile.'
+ : 'The failing checks are completeness problems. They do not mean something happened — they mean you could not prove it had not.'
+ )
+ );
+};
+
/* ── Free-text responders ───────────────────────────────────────────────── */
+/**
+ * Free-text routing.
+ *
+ * Keyword matching, ordered most specific first, because a question mentioning
+ * both "risk" and "candidates" is a risk question. The ordering *is* the logic
+ * here, so each responder reads top to bottom as a priority list.
+ */
const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondControlCenter = (question, f) => {
const q = question.toLowerCase();
- if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f);
- if (has(q, 'unusual', 'anomal', 'suspicious')) return userActivityInsights(f);
+ if (has(q, 'attention', 'urgent', 'blocked', 'stuck', 'wrong')) return attentionRequired(f);
+ if (has(q, 'bottleneck', 'funnel', 'pipeline', 'conversion', 'drop')) return pipelineHealth(f);
+ if (has(q, 'recommend', 'should', 'fix', 'improve', 'risk', 'next')) return recommendations(f);
+ if (has(q, 'usage', 'summary', 'summarize', 'workforce', 'scale', 'how many')) return workforceSummary(f);
+ if (has(q, 'operation', 'velocity', 'speed', 'throughput', 'time to hire', 'hiring')) return hiringOperations(f);
+ if (has(q, 'unusual', 'anomal', 'suspicious')) return unusualActivity(f);
return platformHealth(f);
};
export const respondAdminCandidates = (question, f) => {
const q = question.toLowerCase();
+ if (has(q, 'gap', 'missing', 'incomplete', 'coverage', 'unscored')) return screeningGaps(f);
if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f);
+ if (has(q, 'compare', 'top', 'best', 'strongest', 'shortlist')) return topCandidates(f);
+ if (has(q, 'recommend', 'should', 'next', 'action', 'hire', 'who')) return hiringRecommendations(f);
return recruitmentInsights(f);
};
export const respondActivity = (question, f) => {
const q = question.toLowerCase();
- if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account', 'unusual')) return userActivityInsights(f);
+ if (has(q, 'security', 'breach', 'attributab', 'compliance', 'integrity', 'trace')) return securityInsights(f);
+ if (has(q, 'unusual', 'anomal', 'suspicious', 'odd', 'spike', 'burst')) return unusualActivity(f);
+ if (has(q, 'user', 'who', 'account', 'behaviour', 'behavior', 'busiest')) return userActivityInsights(f);
return auditSummary(f);
};
@@ -321,15 +914,24 @@ export const respondActivity = (question, f) => {
export const CONTROL_CENTER_CAPABILITIES = [
{ id: 'platform-health', label: 'Platform Health', icon: Gauge, run: platformHealth },
+ { id: 'workforce-summary', label: 'Workforce Summary', icon: Users, run: workforceSummary },
+ { id: 'hiring-operations', label: 'Hiring Operations', icon: Activity, run: hiringOperations },
+ { id: 'pipeline-health', label: 'Pipeline Health', icon: GitBranch, run: pipelineHealth },
+ { id: 'attention-required', label: 'Attention Required', icon: BellRing, run: attentionRequired },
{ id: 'recommendations', label: 'Recommendations', icon: Lightbulb, run: recommendations },
];
export const ADMIN_CANDIDATE_CAPABILITIES = [
+ { id: 'candidate-risk', label: 'Candidate Risk', icon: AlertTriangle, run: candidateRiskAnalysis },
{ id: 'recruitment-insights', label: 'Recruitment Insights', icon: TrendingUp, run: recruitmentInsights },
- { id: 'candidate-risk', label: 'Candidate Risk Analysis', icon: AlertTriangle, run: candidateRiskAnalysis },
+ { id: 'screening-gaps', label: 'Screening Gaps', icon: Filter, run: screeningGaps },
+ { id: 'hiring-recommendations', label: 'Hiring Recommendations', icon: Lightbulb, run: hiringRecommendations },
+ { id: 'top-candidates', label: 'Compare Top Candidates', icon: ArrowLeftRight, run: topCandidates },
];
export const ACTIVITY_CAPABILITIES = [
{ id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary },
- { id: 'user-activity', label: 'User Activity Insights', icon: Eye, run: userActivityInsights },
+ { id: 'user-activity', label: 'User Activity', icon: Eye, run: userActivityInsights },
+ { id: 'unusual-activity', label: 'Unusual Activity', icon: AlertTriangle, run: unusualActivity },
+ { id: 'security-insights', label: 'Security Insights', icon: ShieldAlert, run: securityInsights },
];
diff --git a/src/components/ai-assistant/contexts.js b/src/components/ai-assistant/contexts.js
index 7ba7164..cbeca71 100644
--- a/src/components/ai-assistant/contexts.js
+++ b/src/components/ai-assistant/contexts.js
@@ -8,7 +8,7 @@ import {
} from './capabilities/admin';
/**
- * Page contexts for the Krow AI Assistant.
+ * Page contexts for the Owliver dashboard panel.
*
* Deliberately thin. What the user sees on the landing screen — the title,
* greeting, description, placeholders and suggested prompts — is computed from
diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js
index fd13011..1f0cfd1 100644
--- a/src/components/ai-assistant/dynamic.js
+++ b/src/components/ai-assistant/dynamic.js
@@ -84,30 +84,48 @@ const GREETINGS = {
},
'admin.candidates': (f) => {
- if (f.missingCredentials.length) {
- return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
+ if (!f.total) return 'Nothing in the pipeline yet, so there is no pool to analyse.';
+ if (f.needsDeeperReview.length) {
+ return `${plural(f.needsDeeperReview.length, 'candidate')} currently ${verb(f.needsDeeperReview.length, 'needs', 'need')} deeper review.`;
}
- return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} at Elite.`;
+ if (f.singleCandidateRoles.length) {
+ return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`;
+ }
+ return `All ${f.total} candidates are scored, credentialed and comparable.`;
},
'admin.activity': (f) => {
if (!f.activity.length) return 'No activity logged yet.';
- if (f.activity24h.length === 0) {
- return `Quiet day — ${plural(f.activity7d.length, 'event')} this week, none in the last 24 hours.`;
+ const patterns = f.activitySignals.flags.length;
+ if (patterns) {
+ return `${plural(patterns, 'unusual activity pattern')} ${verb(patterns, 'needs', 'need')} attention.`;
}
- return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`;
+ if (f.activity24h.length === 0) {
+ return `Quiet week — ${plural(f.activity7d.length, 'event')} logged, nothing out of pattern.`;
+ }
+ return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week, nothing out of pattern.`;
},
};
-/** Title lines. First names read better than a formal greeting in a small panel. */
+/**
+ * Title lines.
+ *
+ * Every Admin context greets by the clock. That is not decoration: the greeting
+ * is the one line guaranteed to be read, so it has to prove the panel knows when
+ * and where it is before it says anything about the data. A fixed "Good morning"
+ * on an 11pm shift does the opposite.
+ *
+ * First names read better than a formal greeting in a small panel, so the name is
+ * used where one is known.
+ */
const TITLES = {
'employer.overview': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'employer.candidates': () => 'Candidate intelligence',
'employer.analytics': () => 'Analytics assistant',
- 'admin.controlCenter': (f) => `Platform check, ${timeOfDay(f.today)}`,
- 'admin.candidates': () => 'Talent analysis',
- 'admin.activity': () => 'Activity review',
+ 'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
+ 'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
+ 'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
@@ -116,12 +134,20 @@ const DESCRIPTIONS = {
`Reading ${f.total} applicants across ${plural(f.openPositions.length, 'open role')}.`,
'employer.candidates': (f) => `All ${f.total} candidates and their score breakdowns.`,
'employer.analytics': () => 'Every figure on this page, explained or forecast.',
- 'admin.controlCenter': () => 'Health checks, data integrity and what needs intervention.',
- 'admin.candidates': () => 'Talent supply and risk across the candidate pool.',
+ 'admin.controlCenter': (f) =>
+ `Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`,
+ 'admin.candidates': (f) =>
+ `${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`,
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
};
-/** Placeholders phrased as things this page's user would actually type. */
+/**
+ * Placeholders phrased as things this page's user would actually type.
+ *
+ * The Admin ones name Owliver and name the page's subject, because the panel sits
+ * permanently beside the dashboard: someone glancing at it needs the input itself
+ * to say what it is for and what this page can be asked about.
+ */
const PLACEHOLDERS = {
'employer.overview': (f) => [
`Summarize ${timeOfDay(f.today)}'s hiring…`,
@@ -138,9 +164,21 @@ const PLACEHOLDERS = {
'Forecast hiring…',
'Generate an executive summary…',
],
- 'admin.controlCenter': () => ['Show unusual activity…', 'Summarize platform usage…'],
- 'admin.candidates': () => ['Where is the risk in the pool?', 'Summarize recruitment trends…'],
- 'admin.activity': () => ['Show unusual activity…', 'Summarize platform usage…'],
+ 'admin.controlCenter': () => [
+ 'Ask Owliver about platform health…',
+ 'Ask Owliver what needs attention…',
+ 'Ask Owliver about hiring bottlenecks…',
+ ],
+ 'admin.candidates': () => [
+ 'Ask Owliver about candidates…',
+ 'Ask Owliver about screening gaps…',
+ 'Ask Owliver where the risk is…',
+ ],
+ 'admin.activity': () => [
+ 'Ask Owliver about recent activity…',
+ 'Ask Owliver about unusual activity…',
+ 'Ask Owliver about security concerns…',
+ ],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
@@ -195,28 +233,47 @@ const PROMPTS = {
{ label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' },
].filter(Boolean),
+ /* Admin. The first chip is whatever the data has made urgent, so the set
+ reorders itself as the platform's state changes rather than offering the
+ same five questions regardless. */
'admin.controlCenter': (f) => [
+ f.starvedPositions.length
+ ? { label: `${f.starvedPositions.length} roles with no applicants`, prompt: 'What needs attention right now?', capability: 'attention-required' }
+ : { label: 'What needs attention?', prompt: 'What needs attention right now?', capability: 'attention-required' },
{ label: 'Platform health', prompt: 'Run a platform health check', capability: 'platform-health' },
- { label: 'Summarize platform usage', prompt: 'Summarize platform usage', capability: 'platform-health' },
- f.unscoredProfiles.length
- ? { label: `${f.unscoredProfiles.length} unscored profiles`, prompt: 'What should I fix first?', capability: 'recommendations' }
- : { label: 'What should I fix?', prompt: 'What should I fix first?', capability: 'recommendations' },
+ f.bottleneck && f.bottleneck.lost > 0
+ ? { label: `Bottleneck at ${f.bottleneck.to}`, prompt: `Show hiring bottlenecks — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
+ : { label: 'Show hiring bottlenecks', prompt: 'Show hiring bottlenecks in the pipeline', capability: 'pipeline-health' },
+ { label: 'Summarize platform usage', prompt: 'Summarize platform usage and workforce scale', capability: 'workforce-summary' },
+ { label: 'Hiring operations', prompt: 'How are hiring operations performing?', capability: 'hiring-operations' },
+ { label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
],
'admin.candidates': (f) => [
- { label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
+ f.ranked.length >= 2
+ ? { label: `Compare ${f.ranked[0].applicant_name.split(' ')[0]} & ${f.ranked[1].applicant_name.split(' ')[0]}`, prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' }
+ : { label: 'Compare top candidates', prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' },
f.missingCredentials.length
- ? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' }
- : { label: 'Candidate risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
+ ? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' }
+ : { label: 'Show candidate risks', prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' },
+ f.unscreened.length
+ ? { label: `${f.unscreened.length} unscored applicants`, prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' }
+ : { label: 'Find screening gaps', prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' },
+ { label: 'Recommend next actions', prompt: 'Recommend next actions for these candidates', capability: 'hiring-recommendations' },
+ { label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
],
'admin.activity': (f) => [
- { label: 'Audit summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
- { label: 'Show unusual activity', prompt: 'Show me unusual account activity', capability: 'user-activity' },
- f.activity7d.length
- ? { label: `${f.activity7d.length} events this week`, prompt: 'Summarize platform usage this week', capability: 'audit-summary' }
- : null,
- ].filter(Boolean),
+ f.activitySignals.flags.length
+ ? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
+ : { label: 'Show unusual activity', prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' },
+ f.activity24h.length
+ ? { label: `Summarize today's ${f.activity24h.length} events`, prompt: "Summarize today's activity", capability: 'audit-summary' }
+ : { label: "Summarize today's activity", prompt: "Summarize today's activity", capability: 'audit-summary' },
+ { label: 'What changed recently?', prompt: 'What changed recently on the platform?', capability: 'audit-summary' },
+ { label: 'Identify security concerns', prompt: 'Identify security concerns in the audit trail', capability: 'security-insights' },
+ { label: 'Who is most active?', prompt: 'Which accounts are most active?', capability: 'user-activity' },
+ ],
};
@@ -231,7 +288,7 @@ const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] ||
export function buildIntro(contextId, facts, userName) {
const name = firstName(userName);
return {
- title: TITLES[contextId]?.(facts, name) ?? 'Krow AI',
+ title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
greeting: GREETINGS[contextId]?.(facts) ?? '',
description: DESCRIPTIONS[contextId]?.(facts) ?? '',
placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'],
diff --git a/src/components/ai-assistant/index.jsx b/src/components/ai-assistant/index.jsx
index bb682bb..b4c75db 100644
--- a/src/components/ai-assistant/index.jsx
+++ b/src/components/ai-assistant/index.jsx
@@ -1,11 +1,18 @@
/**
- * Krow AI Assistant — the dashboard's contextual productivity assistant.
+ * Owliver — the dashboard's contextual productivity panel.
+ *
+ * Named Owliver in the UI, but architecturally independent of the standalone
+ * Owliver voice product in components/krow/Owliver*. Nothing is shared.
*
* Architecture:
*
- * AssistantPanelProvider open/closed state, resolved page context
- * AssistantTrigger the KROW mark, rendered beside a page's actions
- * AssistantPanel the sliding column the layout renders
+ * AssistantPanelProvider workspace state, resolved page context
+ * AssistantPanel the workspace column the Admin layout renders
+ * AssistantTrigger legacy opt-in button. Not used by Admin: Owliver is
+ * part of the page there, so there is no trigger to
+ * press. It self-hides on unsupported routes, which
+ * is why the pre-redesign pages still holding one
+ * render nothing.
* KrowAssistant the one assistant UI, reused on every page
* provider.js the transport seam — local today, HTTP later
*
diff --git a/src/components/ai-assistant/insights.js b/src/components/ai-assistant/insights.js
index 1c0c294..19451d0 100644
--- a/src/components/ai-assistant/insights.js
+++ b/src/components/ai-assistant/insights.js
@@ -21,6 +21,13 @@ const avg = (values) =>
const DAY_MS = 1000 * 60 * 60 * 24;
+/**
+ * Events an auditor looks at first: they change who is employed or what is being
+ * hired for. Kept here so the fact sheet, the Activity page's severity column and
+ * the assistant's security answers all agree on what "privileged" means.
+ */
+export const PRIVILEGED_EVENTS = ['hire_candidate', 'create_position'];
+
/** `today` is injected rather than read from the clock so answers are stable. */
export function buildFacts({
applications = [],
@@ -105,6 +112,68 @@ export function buildFacts({
return acc;
}, {});
+ /**
+ * Out-of-pattern activity.
+ *
+ * Detected here rather than in the assistant so the greeting, the answer and
+ * any future page section all count the same things. "Two unusual patterns"
+ * has to mean the same two everywhere it is said.
+ *
+ * Each signal is a deviation from this platform's own baseline, not a verdict:
+ * on a live deployment most of them resolve to an integration or a busy
+ * afternoon, and the copy that renders them says so.
+ */
+ const activitySignals = (() => {
+ const privileged = activity.filter((e) => PRIVILEGED_EVENTS.includes(e.event_type));
+
+ const perUser = activity.reduce((acc, e) => {
+ acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 };
+ acc[e.user_email].count += 1;
+ if (PRIVILEGED_EVENTS.includes(e.event_type)) acc[e.user_email].privileged += 1;
+ return acc;
+ }, {});
+
+ const accounts = Object.values(perUser).sort((a, b) => b.count - a.count);
+ const busiest = accounts[0] || null;
+ const busiestShare = busiest ? pct(busiest.count, activity.length) : 0;
+
+ /* A burst is more than three actions from one account inside one hour. */
+ const perAccountHour = activity.reduce((acc, e) => {
+ const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
+ acc[key] = (acc[key] || 0) + 1;
+ return acc;
+ }, {});
+ const bursts = Object.values(perAccountHour).filter((n) => n > 3).length;
+
+ const offHours = activity.filter((e) => {
+ const hour = new Date(e.created_date).getHours();
+ return hour < 6 || hour >= 22;
+ });
+
+ const privilegedShare = pct(privileged.length, activity.length);
+
+ /* The flags, in the order they are worth reading. A count of these is what
+ the greeting reports, so anything added here changes that number. */
+ const flags = [
+ activity.length > 0 && busiestShare >= 50 && 'concentration',
+ bursts > 0 && 'burst',
+ offHours.length > 0 && 'off-hours',
+ activity.length > 0 && since(1).length === 0 && 'silent',
+ privilegedShare > 30 && 'privileged-share',
+ ].filter(Boolean);
+
+ return {
+ accounts,
+ busiest,
+ busiestShare,
+ bursts,
+ offHours,
+ privileged,
+ privilegedShare,
+ flags,
+ };
+ })();
+
return {
today,
@@ -125,6 +194,22 @@ export function buildFacts({
missingCredentials: scored.filter((a) => !(a.certifications || []).length),
narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1),
+ /**
+ * Candidates a human should look at more closely — either because there is
+ * no score to judge them on, or because the record has a gap the score does
+ * not reflect. Deliberately not "weak candidates": every one of these is
+ * under-assessed rather than assessed and found wanting.
+ */
+ needsDeeperReview: applications.filter((a) => {
+ if (!(a.ai_score > 0)) return true;
+ const flaggedInterview = interviews.some(
+ (i) => i.application_id === a.id && (i.integrity_score ?? 100) < 100
+ );
+ return flaggedInterview
+ || !(a.certifications || []).length
+ || !a.years_experience;
+ }),
+
/* Positions */
postings,
openPositions,
@@ -161,6 +246,7 @@ export function buildFacts({
activity7d: since(7),
activity24h: since(1),
eventCounts,
+ activitySignals,
/* Signed-in worker (talent context only) */
profile,
diff --git a/src/components/ai-assistant/placement.js b/src/components/ai-assistant/placement.js
index cd97284..97d0527 100644
--- a/src/components/ai-assistant/placement.js
+++ b/src/components/ai-assistant/placement.js
@@ -1,7 +1,7 @@
import { ASSISTANT_CONTEXTS } from './contexts';
/**
- * Where the Krow AI Assistant appears.
+ * Where the Owliver dashboard panel appears.
*
* The assistant occupies a permanent column in the page layout, so enabling it
* on a page is a layout decision, not a feature flag — which is exactly why the
@@ -12,15 +12,10 @@ import { ASSISTANT_CONTEXTS } from './contexts';
* from `/positions`.
*/
const PLACEMENT = {
- employer: {
- '/': 'employer.overview',
- '/candidates': 'employer.candidates',
- '/analytics': 'employer.analytics',
- },
admin: {
- '/admin': 'admin.controlCenter', // Control Center
- '/candidates': 'admin.candidates', // Candidates Analysis
- '/tracking': 'admin.activity', // Activity
+ '/admin': 'admin.controlCenter',
+ '/admin/candidates-analysis': 'admin.candidates',
+ '/admin/activity': 'admin.activity',
},
};
@@ -28,19 +23,20 @@ const PLACEMENT = {
* Pages that must never carry the assistant, listed explicitly so the intent is
* documented rather than implied by omission.
*
- * Employer: Positions, Position Detail, Create Position, Hired History,
- * Talent Pool, Profile.
- * Admin: everything except the three above.
- * Talent: every page. The talent portal has Owliver — a separate product with
- * its own voice experience, branding and workflow. Krow AI does not
- * appear there and must not replace it.
+ * Admin: every page except Control Center, Candidates Analysis and Activity.
+ * Employer: every page. The contextual panel is an Admin capability.
+ * Talent: every page. The talent portal has the standalone Owliver product,
+ * with its own voice experience, branding and workflow.
*/
export const EXCLUDED_ROUTES = [
- '/positions', '/positions/new', '/positions/:id',
- '/hired', '/talent-pool', '/profile', '/apply',
- '/university', '/university/:id', '/me', '/identity',
+ // Every Employer and Talent route: the contextual panel is Admin-only.
+ '/', '/positions', '/positions/new', '/positions/:id', '/candidates',
+ '/hired', '/talent-pool', '/analytics', '/profile', '/apply',
+ '/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system',
- '/employee', // Talent portal — Owliver's, not Krow AI's.
+ // Admin management surfaces stay focused on their own workflow.
+ '/admin/positions', '/admin/candidates', '/admin/hired',
+ '/admin/talent-pool', '/admin/analytics', '/admin/profile', '/admin/login',
];
/** Returns the context for a role and path, or `null` to render no assistant. */
diff --git a/src/components/ai-assistant/provider.js b/src/components/ai-assistant/provider.js
index a1c71f8..6fd70f5 100644
--- a/src/components/ai-assistant/provider.js
+++ b/src/components/ai-assistant/provider.js
@@ -1,5 +1,5 @@
/**
- * Provider seam for the Krow AI Assistant.
+ * Provider seam for the Owliver dashboard panel.
*
* The UI never generates an answer and never knows where one came from. It calls
* `provider.stream(request)` and renders the snapshots. That single boundary is
diff --git a/src/components/ds/InsightRow.jsx b/src/components/ds/InsightRow.jsx
new file mode 100644
index 0000000..6a9e9fd
--- /dev/null
+++ b/src/components/ds/InsightRow.jsx
@@ -0,0 +1,110 @@
+import * as React from 'react';
+import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Badge } from '@/components/ui/badge';
+
+/**
+ * InsightRow — one item that needs an operator's attention.
+ *
+ * A list of these replaces the usual grid of alert cards. Six cards is a wall;
+ * six rows is a queue you can work through, which is what "Attention Required"
+ * actually is. Severity sits in a left rail so the list can be triaged by
+ * scanning one column.
+ *
+ * `severity: 'critical' | 'warning' | 'info'`
+ */
+
+const SEVERITY = {
+ critical: {
+ icon: ShieldAlert,
+ rail: 'bg-destructive',
+ tile: 'bg-destructive-muted text-destructive',
+ badge: 'destructive',
+ label: 'Critical',
+ },
+ warning: {
+ icon: AlertTriangle,
+ rail: 'bg-warning',
+ tile: 'bg-warning-muted text-warning',
+ badge: 'warning',
+ label: 'Warning',
+ },
+ info: {
+ icon: Info,
+ rail: 'bg-krow-blue',
+ tile: 'bg-krow-blue-tint text-krow-blue',
+ badge: 'info',
+ label: 'Info',
+ },
+ trend: {
+ icon: TrendingDown,
+ rail: 'bg-ink-4',
+ tile: 'bg-surface-sunken text-ink-3',
+ badge: 'neutral',
+ label: 'Trend',
+ },
+};
+
+export function InsightRow({
+ severity = 'info',
+ title,
+ detail,
+ /** Short right-aligned figure — a count, an age, a percentage. */
+ metric,
+ metricLabel,
+ /** Entity the item concerns, e.g. a position or candidate name. */
+ entity,
+ onClick,
+ actionLabel = 'Review',
+ className,
+}) {
+ const s = SEVERITY[severity] || SEVERITY.info;
+ const Comp = onClick ? 'button' : 'div';
+
+ return (
+
+ {/* Severity rail — one column to scan rather than five colours to decode. */}
+
+
+
+
+
+
+
+
+ {title}
+ {entity && {entity}}
+
+ {detail && {detail}}
+
+
+ {metric != null && (
+
+ {metric}
+ {metricLabel && {metricLabel}}
+
+ )}
+
+ {onClick && (
+
+ {actionLabel}
+
+
+ )}
+
+ {s.label}
+
+ );
+}
+
+/** The list container — dividers between rows, nothing else. */
+export function InsightList({ children, className }) {
+ return
{children}
;
+}
diff --git a/src/components/ds/MetricStrip.jsx b/src/components/ds/MetricStrip.jsx
new file mode 100644
index 0000000..ce825ff
--- /dev/null
+++ b/src/components/ds/MetricStrip.jsx
@@ -0,0 +1,113 @@
+import * as React from 'react';
+import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+/**
+ * MetricStrip — a row of operational figures.
+ *
+ * The Admin surface reports six to eight numbers at once. As individual cards
+ * that becomes a wall of boxes competing with the content below it, so this
+ * presents them as one banded strip: hairline dividers instead of borders, one
+ * shared surface, numbers aligned on a baseline. Dense enough for an operations
+ * console, still readable.
+ *
+ * `items: [{ label, value, delta?, deltaLabel?, invertTrend?, sub?, icon?, tone? }]`
+ *
+ * `invertTrend` exists because up is not always good: time-to-hire rising is
+ * bad, and colouring it green would misinform.
+ */
+
+const TONE = {
+ default: 'text-ink-1',
+ brand: 'text-krow-blue',
+ success: 'text-success',
+ warning: 'text-warning',
+ risk: 'text-destructive',
+};
+
+function Delta({ delta, label, invert }) {
+ if (typeof delta !== 'number') return null;
+
+ const flat = delta === 0;
+ const good = invert ? delta < 0 : delta > 0;
+ const Icon = flat ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight;
+
+ return (
+
+
+ {flat ? '0%' : `${Math.abs(delta)}%`}
+ {label && {label}}
+
+ );
+}
+
+export function MetricStrip({ items = [], columns, className, loading = false }) {
+ // Column count follows the data by default, so the strip divides evenly
+ // instead of leaving an orphan cell.
+ const count = columns ?? Math.min(items.length, 6);
+ /**
+ * Five and six columns wait for `xl` rather than `lg`.
+ *
+ * The strip lives in the Admin main column, which is narrower than the
+ * viewport whenever the Owliver workspace is open — roughly 400px narrower. A
+ * `lg` breakpoint measures the window and so cheerfully packs six cells into a
+ * 580px column, where every label clips. Two rows of three is the better
+ * answer at that width.
+ */
+ const gridCols = {
+ 2: 'sm:grid-cols-2',
+ 3: 'sm:grid-cols-3',
+ 4: 'grid-cols-2 sm:grid-cols-4',
+ 5: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-5',
+ 6: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
+ }[count] || 'sm:grid-cols-3';
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.icon &&
}
+ {/* Wraps rather than truncates. A clipped label is worse than a
+ two-line one, and grid rows stretch together so the strip stays
+ even either way. */}
+
+ {item.label}
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+ {item.value}
+
+
+
+ )}
+
+ {item.sub &&
{item.sub}
}
+
+ ))}
+
+ );
+}
diff --git a/src/components/ds/index.js b/src/components/ds/index.js
index 161e78f..e7bd15e 100644
--- a/src/components/ds/index.js
+++ b/src/components/ds/index.js
@@ -101,6 +101,8 @@ export {
/* ── Data display ───────────────────────────────────────────────────────── */
export { KpiCard, MetricCard } from './KpiCard';
+export { MetricStrip } from './MetricStrip';
+export { InsightList, InsightRow } from './InsightRow';
export { DataTable } from './DataTable';
export { FilterBar } from './FilterBar';
export { Timeline } from './Timeline';
diff --git a/src/components/krow/CandidateCard.jsx b/src/components/krow/CandidateCard.jsx
index d2d8038..72ae1d8 100644
--- a/src/components/krow/CandidateCard.jsx
+++ b/src/components/krow/CandidateCard.jsx
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
-import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase } from 'lucide-react';
+import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase, ExternalLink } from 'lucide-react';
import { Image } from '@/components/ui/image';
import { getScoreBand } from '@/lib/talentHome';
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
@@ -63,6 +64,7 @@ function ScoreRing({ score }) {
}
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
+ const navigate = useNavigate();
const position = jobTitle || application.job_title || '';
const status = application.status || 'applied';
const style = STATUS_STYLES[status] || STATUS_STYLES.applied;
@@ -81,10 +83,10 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
{rank}
- {/* Avatar */}
-