update chatbox

This commit is contained in:
2026-08-08 00:39:16 +05:30
parent 90f14a73b2
commit 0cae250049
15 changed files with 1952 additions and 913 deletions

View File

@@ -1,132 +1,47 @@
import * as React from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
/**
* Renders the light markup the capabilities emit — `**bold**`, bullets,
* numbered items, `_asides_`, and ✓/⚠ check lines.
* The thinking state, shown between sending and the first block arriving.
*
* A full markdown renderer is available in the project, but this content is
* produced locally in a known shape, and parsing five constructs keeps the
* assistant's typography inside the design system rather than inheriting a
* library's defaults.
* Labelled as well as animated: three moving dots mean nothing to a screen
* reader, and the visually-hidden text is what tells one that a reply is coming.
*/
const BOLD = /\*\*(.+?)\*\*/g;
function inline(line, keyPrefix) {
const parts = [];
let last = 0;
let match;
BOLD.lastIndex = 0;
while ((match = BOLD.exec(line)) !== null) {
if (match.index > last) parts.push(line.slice(last, match.index));
parts.push(
<strong key={`${keyPrefix}-${match.index}`} className="font-semibold text-ink-1">
{match[1]}
</strong>
);
last = match.index + match[0].length;
}
if (last < line.length) parts.push(line.slice(last));
return parts.length ? parts : line;
}
export function RichText({ text, className }) {
const lines = React.useMemo(() => String(text).split('\n'), [text]);
return (
<div className={cn('space-y-1.5 text-body-sm text-ink-2', className)}>
{lines.map((line, i) => {
if (!line.trim()) return <div key={i} className="h-1.5" aria-hidden="true" />;
/* Aside — a caveat the capabilities wrap in underscores. */
if (line.startsWith('_') && line.endsWith('_')) {
return (
<p key={i} className="border-l-2 border-border pl-2.5 text-caption italic text-ink-3 leading-relaxed">
{inline(line.slice(1, -1), i)}
</p>
);
}
/* Bullet. */
if (line.startsWith('• ')) {
return (
<div key={i} className="flex gap-2 leading-relaxed">
<span className="text-krow-blue shrink-0" aria-hidden="true">•</span>
<span className="min-w-0">{inline(line.slice(2), i)}</span>
</div>
);
}
/* Indented continuation of a numbered item. */
if (line.startsWith(' ')) {
return (
<p key={i} className="pl-6 text-caption text-ink-3 leading-relaxed">
{inline(line.trim(), i)}
</p>
);
}
/* Numbered item. */
const numbered = line.match(/^(\d+)\.\s+(.*)$/);
if (numbered) {
return (
<div key={i} className="flex gap-2.5 leading-relaxed">
<span className="grid place-items-center w-4 h-4 mt-0.5 shrink-0 rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{numbered[1]}
</span>
<span className="min-w-0">{inline(numbered[2], i)}</span>
</div>
);
}
/* Health-check line. */
if (line.startsWith('✓ ') || line.startsWith('⚠ ')) {
const ok = line.startsWith('✓');
return (
<div key={i} className="flex gap-2 leading-relaxed">
<span className={cn('shrink-0 font-semibold', ok ? 'text-success' : 'text-warning')} aria-hidden="true">
{ok ? '✓' : '⚠'}
</span>
<span className="min-w-0">{inline(line.slice(2), i)}</span>
</div>
);
}
return <p key={i} className="leading-relaxed">{inline(line, i)}</p>;
})}
</div>
);
}
/** The three-dot indicator shown before the first delta arrives. */
export function ThinkingIndicator() {
return (
<div className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-3 py-2.5 shadow-xs">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 rounded-full bg-ink-4 animate-typing"
style={{ animationDelay: `${i * 0.18}s` }}
/>
))}
<span className="sr-only">Assistant is thinking</span>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-krow-blue-tint text-krow-blue">
<Sparkles className="h-3 w-3" aria-hidden="true" />
</span>
<span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1.5 w-1.5 animate-typing rounded-full bg-ink-4"
style={{ animationDelay: `${i * 0.18}s` }}
/>
))}
</span>
<span className="sr-only">Krow AI is preparing a response</span>
</div>
);
}
/**
* One turn in the conversation. The user's own words sit in a filled bubble; the
* assistant's answer runs full width, because these answers are structured
* multi-paragraph reports and a chat bubble would waste a third of a 380px
* column on padding.
* One turn.
*
* The user's words sit in a filled bubble; the assistant's reply runs the full
* width of the panel. That asymmetry is deliberate — these replies are reports
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them.
*/
export function Message({ role, text, streaming, stopped }) {
export const Message = React.memo(({ role, text, blocks, streaming, stopped }) => {
if (role === 'user') {
return (
<div className="flex justify-end animate-slide-up">
<div className="max-w-[85%] rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
<div className="flex justify-end">
<div className="max-w-[85%] animate-slide-up rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
{text}
</div>
</div>
@@ -135,16 +50,24 @@ export function Message({ role, text, streaming, stopped }) {
return (
<div className="animate-slide-up">
<RichText text={text} />
{streaming && (
<span
className="inline-block w-1.5 h-3.5 ml-0.5 -mb-0.5 bg-krow-blue animate-caret"
aria-hidden="true"
/>
)}
{stopped && (
<p className="mt-1.5 text-caption italic text-ink-4">Stopped early.</p>
)}
<div className="mb-2 flex items-center gap-1.5">
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-krow-blue-tint text-krow-blue">
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
</span>
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Krow AI</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
</div>
);
});
Message.displayName = 'Message';
/** A subtle divider between turns, so a long thread stays readable. */
export function TurnDivider() {
return <div className="h-px bg-border/60" role="presentation" />;
}
export { cn };

View File

@@ -4,9 +4,27 @@ import { cn } from '@/lib/utils';
import KrowAssistant from './KrowAssistant';
import { useAssistantPanel } from './AssistantPanelContext';
/** Panel width, plus the gutter separating it from the dashboard. */
const PANEL_WIDTH = 380;
const DEFAULT_WIDTH = 380;
const EXPANDED_WIDTH = 620;
/** Gutter separating the panel from the dashboard. */
const GUTTER = 24;
/** Expanded must never dominate: the dashboard stays the primary experience. */
const MAX_VIEWPORT_SHARE = 0.42;
/** Tracks viewport width so the expanded panel can be clamped on smaller screens. */
function useViewportWidth() {
const [width, setWidth] = React.useState(() =>
typeof window === 'undefined' ? 1440 : window.innerWidth
);
React.useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
/**
* AssistantPanel — the sliding column the layout renders.
@@ -15,23 +33,30 @@ const GUTTER = 24;
* dashboard reflow smoothly instead of being covered, and what lets closing
* restore the original layout exactly.
*
* Two things about the structure matter, and both were bugs before:
* Two structural details matter, and both were bugs at one point:
*
* 1. The clip lives *inside* the sticky element, not around it. An ancestor with
* `overflow: hidden` becomes the nearest scroll container, which silently
* disables `position: sticky` on everything beneath it — the panel computed
* as sticky but never actually stuck.
* 1. The clip lives *inside* the sticky element. An ancestor with
* `overflow: hidden` becomes the nearest scroll container and silently
* disables `position: sticky` beneath it.
* 2. The aside stretches to the shell's full height (`self-stretch`), so its
* sticky context runs the whole page rather than ending partway down and
* leaving empty space beside a long dashboard.
*
* 2. The aside must stretch to the shell's full height. As a flex item under
* `items-start` it collapsed to its own height, so its sticky context ended
* partway down the page and the panel scrolled away, leaving empty space
* beside a long dashboard.
*
* With both fixed the panel stays pinned for the whole scroll, and because the
* assistant is a fixed-height frame, only its conversation scrolls.
* Expanding widens the same panel in place — it is an analysis workspace inside
* the application layout, not a fullscreen takeover, and never covers the
* dashboard.
*/
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
const { context, isOpen, close } = useAssistantPanel();
const { context, isOpen, isExpanded, close, expand, restore } = useAssistantPanel();
const viewportWidth = useViewportWidth();
// Clamped so a wide panel cannot squeeze the dashboard on a small screen.
const panelWidth = isExpanded
? Math.max(DEFAULT_WIDTH, Math.min(EXPANDED_WIDTH, Math.round(viewportWidth * MAX_VIEWPORT_SHARE)))
: DEFAULT_WIDTH;
const trackWidth = panelWidth + GUTTER;
const easing = [0.65, 0, 0.35, 1];
return (
<AnimatePresence initial={false}>
@@ -40,34 +65,40 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
key="assistant"
aria-label="Krow AI Assistant"
initial={{ width: 0, opacity: 0 }}
animate={{ width: PANEL_WIDTH + GUTTER, opacity: 1 }}
animate={{ width: trackWidth, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
/* No `overflow-hidden` here — see (1) above. `self-stretch` keeps the
sticky context running the full height of the shell — see (2). */
transition={{ duration: 0.3, ease: easing }}
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2). */
className="relative shrink-0 self-stretch"
>
<div className={cn('sticky', stickyClassName)}>
{/* The clip: contains the slide-in and the fixed panel width while
the track animates open, without breaking the sticky above it. */}
<div className="overflow-hidden" style={{ width: PANEL_WIDTH + GUTTER }}>
{/* The clip: contains the slide-in and the panel width while the
track animates, without breaking the sticky above it. */}
<motion.div
className="overflow-hidden"
animate={{ width: trackWidth }}
transition={{ duration: 0.3, ease: easing }}
>
<motion.div
className="pl-6"
initial={{ x: 32, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 32, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
transition={{ duration: 0.3, ease: easing }}
>
{/* Keyed on the context so switching pages gives a clean panel
and aborts any response still streaming for the last one. */}
<KrowAssistant
key={context.id}
context={context}
expanded={isExpanded}
onClose={close}
onExpand={expand}
onRestore={restore}
className={panelHeightClassName}
/>
</motion.div>
</div>
</motion.div>
</div>
</motion.aside>
)}

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import { resolveAssistantContext } from './placement';
/**
* Open/closed state for the Krow AI Assistant.
* Window state for the Krow AI Assistant: open/closed and default/expanded.
*
* Lives in context rather than in the layout so the trigger can sit anywhere in
* the page — beside that page's own actions — without the layout having to know
@@ -14,23 +14,33 @@ import { resolveAssistantContext } from './placement';
const AssistantPanelContext = React.createContext(null);
const STORAGE_KEY = 'krow_assistant_open';
const OPEN_KEY = 'krow_assistant_open';
const EXPANDED_KEY = 'krow_assistant_expanded';
/**
* Whether the panel was open before. Kept for the session so moving between
* supported pages does not close a panel the user deliberately opened, while a
* new session still starts clean on the dashboard.
* Window state is kept for the session so moving between supported pages does
* not collapse a panel the user deliberately opened or expanded, while a new
* session still starts clean on the dashboard.
*/
function readStoredOpen() {
function readFlag(key) {
try {
return sessionStorage.getItem(STORAGE_KEY) === '1';
return sessionStorage.getItem(key) === '1';
} catch {
return false;
}
}
function writeFlag(key, value) {
try {
sessionStorage.setItem(key, value ? '1' : '0');
} catch {
// Private mode — state simply resets next session.
}
}
export function AssistantPanelProvider({ role, pathname, children }) {
const [open, setOpen] = React.useState(readStoredOpen);
const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY));
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
const context = React.useMemo(
() => resolveAssistantContext(role, pathname),
@@ -38,36 +48,47 @@ export function AssistantPanelProvider({ role, pathname, children }) {
);
const supported = Boolean(context);
// A stored "open" must not force the panel onto a page that has no assistant.
// A stored "open" must not force the panel onto a page with no assistant.
const isOpen = supported && open;
const isExpanded = isOpen && expanded;
const persist = React.useCallback((next) => {
const setOpenState = React.useCallback((next) => {
setOpen(next);
try {
sessionStorage.setItem(STORAGE_KEY, next ? '1' : '0');
} catch {
// Private mode — the panel simply reverts to closed next session.
}
writeFlag(OPEN_KEY, next);
}, []);
/* Escape closes the panel, as it does for every other dismissible surface. */
const setExpandedState = React.useCallback((next) => {
setExpanded(next);
writeFlag(EXPANDED_KEY, next);
}, []);
/**
* Escape steps back one level rather than closing outright: expanded → default
* → closed. Collapsing a workspace and dismissing it are different intentions,
* and one key press should not do both.
*/
React.useEffect(() => {
if (!isOpen) return undefined;
const onKey = (e) => {
if (e.key === 'Escape') persist(false);
if (e.key !== 'Escape') return;
if (isExpanded) setExpandedState(false);
else setOpenState(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, persist]);
}, [isOpen, isExpanded, setExpandedState, setOpenState]);
const value = React.useMemo(() => ({
context,
supported,
isOpen,
open: () => persist(true),
close: () => persist(false),
toggle: () => persist(!isOpen),
}), [context, supported, isOpen, persist]);
isExpanded,
open: () => setOpenState(true),
close: () => setOpenState(false),
toggle: () => setOpenState(!isOpen),
expand: () => setExpandedState(true),
restore: () => setExpandedState(false),
}), [context, supported, isOpen, isExpanded, setOpenState, setExpandedState]);
return (
<AssistantPanelContext.Provider value={value}>
@@ -79,7 +100,7 @@ export function AssistantPanelProvider({ role, pathname, children }) {
/**
* Panel state for triggers and the layout.
*
* Returns a safe inert value outside a provider, so a page rendering an
* Returns an inert value outside a provider, so a page rendering an
* `AssistantTrigger` in isolation (a test, a storybook) does not crash.
*/
export function useAssistantPanel() {
@@ -87,8 +108,11 @@ export function useAssistantPanel() {
context: null,
supported: false,
isOpen: false,
isExpanded: false,
open: () => {},
close: () => {},
toggle: () => {},
expand: () => {},
restore: () => {},
};
}

View File

@@ -1,33 +1,48 @@
import * as React from 'react';
import { RotateCcw, Sparkles, X } from 'lucide-react';
import { Maximize2, Minimize2, RotateCcw, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import { KROW_LOGO_URL } from '@/assets/brand';
import { useAssistantFacts, useConversation } from './useAssistant';
import { Message, ThinkingIndicator } from './AssistantMessage';
import { buildIntro, buildPrompts } from './dynamic';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
/**
* Krow AI Assistant — the dashboard's contextual assistant.
*
* One component, reused on every supported page; only `context` differs.
*
* The layout is a fixed three-part frame — header, conversation, composer —
* where only the conversation scrolls. That is what lets the panel stay pinned
* beside a long dashboard without ever growing the page or losing its input.
* The frame is fixed at three parts — header, body, composer — and only the body
* scrolls. That is what lets the panel stay pinned beside a long dashboard
* without ever growing the page or scrolling its own input out of reach.
*
* The empty state is deliberately a conversational entry point rather than a
* menu: an icon, a question, and an input. Suggested chips fill the input rather
* than firing an action, so the user always sees the question before it is asked
* and can edit it — which is what makes this read as an assistant instead of a
* second dashboard.
* Two states share that frame:
*
* Independent of Owliver. It talks to a provider (see provider.js) which can
* later be pointed at an external service without changing anything here.
* - **Landing.** One centred block: mark, contextual label, greeting,
* description, input, suggestions. Deliberately light — it is an invitation,
* not a page, so nothing in it competes with the dashboard beside it.
* - **Conversation.** The thread takes the body and the composer returns to the
* bottom, because from then on the thread is what the user is reading.
*
* `expanded` widens the same panel into an analysis workspace. `data-wide` lets
* individual response blocks use the extra room (a KPI row goes to three
* columns) without any of them needing to know the panel's pixel width.
*
* Independent of Owliver. Answers come from a provider (see provider.js) that
* can later be pointed at an external service without changing anything here.
*/
export default function KrowAssistant({ context, className, onClose }) {
export default function KrowAssistant({
context,
className,
expanded = false,
onClose,
onExpand,
onRestore,
}) {
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id,
@@ -38,23 +53,46 @@ export default function KrowAssistant({ context, className, onClose }) {
const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending;
/* Follow the newest content as it streams. */
/* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */
const intro = React.useMemo(
() => buildIntro(context.id, facts, facts.profile?.full_name),
[context.id, facts]
);
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
visibly lags the text. */
React.useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, pending]);
/**
* A chip's prompt resolves to its capability so the answer is precise, while
* the same question typed by hand still resolves through keyword matching.
*/
const submit = (text) => {
const question = text.trim();
const submit = React.useCallback((text) => {
const question = String(text).trim();
if (!question || busy) return;
const chip = context.chips.find((c) => c.prompt === question);
send({ question, capability: chip?.capability ?? null });
send({ question });
setInput('');
};
}, [busy, send]);
/* A chip is already a complete question, so it runs on click. */
const runPrompt = React.useCallback((prompt) => {
if (busy) return;
setInput('');
send({ question: prompt.prompt, capability: prompt.capability });
}, [busy, send]);
const composer = (
<PromptInput
value={input}
onChange={setInput}
onSubmit={submit}
onStop={stop}
busy={busy}
placeholders={intro.placeholders}
/>
);
return (
<Surface
@@ -62,13 +100,15 @@ export default function KrowAssistant({ context, className, onClose }) {
radius="xl"
padding="none"
elevation="sm"
data-wide={expanded || undefined}
className={cn('flex flex-col overflow-hidden', className)}
role="region"
aria-label="Krow AI Assistant"
>
{/* Header — fixed */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-4 py-3">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
<Sparkles className="h-4 w-4" aria-hidden="true" />
{/* Header */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
@@ -76,92 +116,101 @@ export default function KrowAssistant({ context, className, onClose }) {
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
</div>
{messages.length > 0 && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
{onClose && (
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
)}
{/* Window controls */}
<div className="flex items-center gap-0.5">
{messages.length > 0 && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
{expanded
? onRestore && (
<IconButton icon={Minimize2} label="Restore panel width" variant="ghost" size="sm" onClick={onRestore} />
)
: onExpand && (
<IconButton icon={Maximize2} label="Expand to workspace" variant="ghost" size="sm" onClick={onExpand} />
)}
{onClose && (
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
)}
</div>
</div>
{/* Conversation — the only scrolling region */}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto" aria-live="polite">
{isEmpty ? (
/* Landing: an invitation to ask, not a list of features. */
<div className="flex h-full flex-col items-center justify-center px-6 py-8 text-center animate-fade-in">
<span className="relative mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-white shadow-brand ring-1 ring-krow-blue/10">
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-5 w-auto object-contain" />
<span
aria-hidden="true"
className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
>
<Sparkles className="h-2.5 w-2.5" />
{isEmpty ? (
/* ── Landing: one compact centred block ───────────────────────── */
<div className="flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-4 py-4">
{/* Capped so the block stays a tight composition when expanded. */}
<div className="mx-auto w-full max-w-sm animate-fade-in space-y-3.5">
<div className="flex flex-col items-center text-center">
<span className="relative mb-2.5 grid h-10 w-10 place-items-center rounded-xl bg-white shadow-brand ring-1 ring-krow-blue/10">
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-3.5 w-auto object-contain" />
<span
aria-hidden="true"
className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
>
<Sparkles className="h-2 w-2" />
</span>
</span>
</span>
<h3 className="font-heading text-title text-ink-1">
What would you like to analyze today?
</h3>
<p className="mt-2 max-w-[19rem] text-body-sm leading-relaxed text-ink-3">
{context.tagline}
</p>
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">
{intro.title}
</p>
<h3 className="mt-1 text-balance font-heading text-body-lg font-semibold leading-snug text-ink-1">
{intro.greeting}
</h3>
<p className="mt-1 text-caption leading-relaxed text-ink-3">{intro.description}</p>
</div>
{composer}
<PromptChips prompts={prompts} onSelect={runPrompt} />
</div>
) : (
<div className="space-y-4 px-4 py-4">
</div>
) : (
/* ── Conversation ─────────────────────────────────────────────── */
<>
<div
ref={scrollRef}
className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4"
aria-live="polite"
aria-relevant="additions text"
>
{messages.map((message, i) => (
<Message key={i} role={message.role} text={message.text} stopped={message.stopped} />
<React.Fragment key={i}>
{i > 0 && message.role === 'user' && <TurnDivider />}
<Message
role={message.role}
text={message.text}
blocks={message.blocks}
stopped={message.stopped}
/>
</React.Fragment>
))}
{pending?.thinking && <ThinkingIndicator />}
{pending && !pending.thinking && <Message role="assistant" text={pending.text} streaming />}
{pending && !pending.thinking && (
<Message role="assistant" blocks={pending.blocks} streaming />
)}
{error && <Alert tone="destructive">{error}</Alert>}
</div>
)}
</div>
{/* Suggested prompts + composer — fixed */}
<div className="shrink-0 space-y-2.5 border-t border-white/60 px-3 pb-3 pt-2.5">
{!busy && (
/* On the landing there is room to show every suggestion, so they wrap.
Once a conversation is underway the space belongs to the thread, so
they collapse to a single scrollable row. */
<div
className={cn(
'flex gap-1.5',
isEmpty ? 'flex-wrap justify-center' : 'overflow-x-auto no-scrollbar'
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
{/* Capped in conversation: the vertical space belongs to the thread.
Expanded fits more per line, so it can afford one more. */}
{!busy && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}
max={expanded ? 4 : 3}
align="start"
/>
)}
>
{context.chips.map((chip) => (
<button
key={chip.label}
type="button"
/* Fills the input — never sends, never navigates. */
onClick={() => setInput(chip.prompt)}
className="shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-caption
text-ink-3 transition-colors duration-base hover:border-krow-blue/40
hover:bg-krow-blue-tint hover:text-krow-blue focus-ring"
>
{chip.label}
</button>
))}
{composer}
<p className="px-1 text-[10px] leading-tight text-ink-4">
Krow AI reads this page&apos;s data. Check anything you act on.
</p>
</div>
)}
<PromptInput
value={input}
onChange={setInput}
onSubmit={submit}
onStop={stop}
busy={busy}
placeholders={context.placeholders}
size={isEmpty ? 'large' : 'default'}
/>
<p className="px-1 text-[10px] leading-tight text-ink-4">
Krow AI reads this page&apos;s data. Check anything you act on.
</p>
</div>
</>
)}
</Surface>
);
}

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* PromptChips — suggested prompts.
*
* Clicking a chip runs it immediately. A chip is already a complete question, so
* making the user press Enter afterwards adds a step that buys nothing.
*
* Chips always wrap onto as many lines as they need. The earlier horizontal-scroll
* variant is gone: a scroller can always leave a chip half-visible at the edge,
* and a half-visible button is a bug no amount of fade masking fixes. Wrapping
* makes clipping structurally impossible instead of merely unlikely.
*
* `max` caps how many are offered in a conversation, where vertical space belongs
* to the thread rather than to suggestions.
*
* Arrow keys move between chips, so the whole set is one tab stop.
*/
export function PromptChips({ prompts = [], onSelect, max, align = 'center', className }) {
const chipRefs = React.useRef([]);
const visible = max ? prompts.slice(0, max) : prompts;
const onKeyDown = (event, index) => {
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
if (!delta) return;
const next = chipRefs.current[index + delta];
if (!next) return;
event.preventDefault();
next.focus();
};
if (!visible.length) return null;
return (
<div
role="group"
aria-label="Suggested prompts"
className={cn(
'flex flex-wrap gap-1.5',
align === 'center' ? 'justify-center' : 'justify-start',
className
)}
>
{visible.map((prompt, i) => (
<button
key={prompt.label}
ref={(el) => { chipRefs.current[i] = el; }}
type="button"
onClick={() => onSelect(prompt)}
onKeyDown={(e) => onKeyDown(e, i)}
title={prompt.prompt}
className={cn(
'rounded-full border border-border bg-surface px-2.5 py-1',
'text-caption font-medium text-ink-2 shadow-xs',
'transition-all duration-base ease-out',
'hover:-translate-y-px hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:text-krow-blue hover:shadow-sm',
'active:translate-y-0 active:shadow-xs',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-1'
)}
>
{prompt.label}
</button>
))}
</div>
);
}

View File

@@ -20,8 +20,6 @@ export function PromptInput({
onStop,
busy = false,
placeholders = ['Ask anything…'],
/** Larger treatment for the landing state, where the input is the focus. */
size = 'default',
autoFocus = false,
className,
}) {
@@ -38,16 +36,29 @@ export function PromptInput({
}, [value]);
/* Rotate the examples only while the field is idle — changing the placeholder
under someone who is mid-thought is distracting. */
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(
() => setPlaceholderIndex((i) => (i + 1) % placeholders.length),
3800
);
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]);
@@ -64,36 +75,52 @@ export function PromptInput({
}
};
const large = size === 'large';
return (
<form
onSubmit={(e) => { e.preventDefault(); submit(); }}
className={cn(
'relative flex items-end gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
large ? 'px-4 py-3' : 'px-3 py-2.5',
'px-3 py-2',
focused
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15'
: 'border-border shadow-md hover:border-krow-blue/30',
className
)}
>
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={placeholders[placeholderIndex]}
aria-label="Ask Krow AI"
className={cn(
'flex-1 min-w-0 resize-none bg-transparent leading-relaxed text-ink-1',
'placeholder:text-ink-4 outline-none no-scrollbar',
large ? 'text-body py-1' : 'text-body-sm py-0.5'
<div className="relative min-w-0 flex-1">
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={busy}
aria-label="Ask Krow AI"
aria-busy={busy || undefined}
className={cn(
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
'no-scrollbar outline-none disabled:cursor-wait disabled:text-ink-3',
'py-0.5 text-body-sm'
)}
/>
{/* Animated placeholder. Hidden from assistive tech — the textarea's
aria-label is the accessible name. */}
{!value && (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 truncate text-ink-4 transition-opacity duration-base',
'py-0.5 text-body-sm',
fading ? 'opacity-0' : 'opacity-100'
)}
>
{busy ? 'Thinking…' : placeholders[placeholderIndex]}
</span>
)}
/>
</div>
{busy ? (
<button
@@ -103,10 +130,10 @@ export function PromptInput({
className={cn(
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
large ? 'w-9 h-9' : 'w-8 h-8'
'h-8 w-8'
)}
>
<Square className={large ? 'w-3.5 h-3.5' : 'w-3 h-3'} aria-hidden="true" />
<Square className="h-3 w-3" aria-hidden="true" />
</button>
) : (
<button
@@ -117,10 +144,10 @@ export function PromptInput({
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
large ? 'w-9 h-9' : 'w-8 h-8'
'h-8 w-8'
)}
>
<ArrowUp className={large ? 'w-4 h-4' : 'w-3.5 h-3.5'} aria-hidden="true" />
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</form>

View File

@@ -0,0 +1,409 @@
import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Check, TriangleAlert } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
/**
* Renderers for response blocks.
*
* Every block is drawn with the same tokens as the dashboard — the ink scale,
* the shared radius, the score-band colours — so a generated report looks like
* it belongs to Krow rather than to a chat library.
*
* Blocks are memoized individually: a streaming response re-renders on every
* snapshot, and settled blocks must not re-render with it.
*/
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
* carried by blocks, not by markup inside a paragraph. */
function Inline({ value }) {
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i} className="font-semibold text-ink-1">{part.slice(2, -2)}</strong>;
}
if (part.startsWith('_') && part.endsWith('_')) {
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
}
return part;
});
}
const TONE_TEXT = {
info: 'text-krow-blue',
success: 'text-success',
warning: 'text-warning',
risk: 'text-destructive',
neutral: 'text-ink-3',
};
const TONE_SURFACE = {
info: 'bg-krow-blue-tint border-krow-blue/15',
success: 'bg-success-muted border-success/15',
warning: 'bg-warning-muted border-warning/20',
risk: 'bg-destructive-muted border-destructive/15',
neutral: 'bg-surface-subtle border-border',
};
const TONE_BADGE = {
info: 'info', success: 'success', warning: 'warning', risk: 'destructive', neutral: 'neutral',
};
/* ── Individual blocks ──────────────────────────────────────────────────── */
const TextBlock = React.memo(({ block }) => (
<p className="text-body-sm leading-relaxed text-ink-2">
<Inline value={block.text} />
</p>
));
TextBlock.displayName = 'TextBlock';
const HeadingBlock = React.memo(({ block }) => (
<div className="space-y-0.5">
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
</div>
));
HeadingBlock.displayName = 'HeadingBlock';
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
const KpisBlock = React.memo(({ block }) => (
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
{block.items.map((item) => (
<div
key={item.label}
className="rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs"
>
<p className="truncate text-[10px] uppercase tracking-wide text-ink-4">{item.label}</p>
<div className="mt-0.5 flex items-baseline gap-1.5">
<span className={cn(
'font-heading text-title font-bold tabular-nums',
item.tone ? TONE_TEXT[item.tone] : 'text-ink-1'
)}>
{item.value}
</span>
{typeof item.delta === 'number' && item.delta !== 0 && (
<span className={cn(
'inline-flex items-center text-[10px] font-semibold',
item.delta > 0 ? 'text-success' : 'text-destructive'
)}>
{item.delta > 0
? <ArrowUpRight className="h-2.5 w-2.5" aria-hidden="true" />
: <ArrowDownRight className="h-2.5 w-2.5" aria-hidden="true" />}
{Math.abs(item.delta)}%
</span>
)}
</div>
{item.sub && <p className="mt-0.5 truncate text-[10px] text-ink-4">{item.sub}</p>}
</div>
))}
</div>
));
KpisBlock.displayName = 'KpisBlock';
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
const StatusBlock = React.memo(({ block }) => (
<ul className="space-y-1.5">
{block.items.map((item) => (
<li key={item.label} className="flex items-start gap-2">
<span
className={cn(
'mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full',
item.ok ? 'bg-success-muted text-success' : 'bg-warning-muted text-warning'
)}
>
{item.ok
? <Check className="h-2.5 w-2.5" aria-hidden="true" />
: <TriangleAlert className="h-2.5 w-2.5" aria-hidden="true" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm font-medium text-ink-1">{item.label}</span>
{item.value && (
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-2">
{item.value}
</span>
)}
</div>
{item.note && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.note}</p>}
</div>
<span className="sr-only">{item.ok ? 'Passing' : 'Needs attention'}</span>
</li>
))}
</ul>
));
StatusBlock.displayName = 'StatusBlock';
const MetersBlock = React.memo(({ block }) => (
<div className="space-y-2.5">
{block.items.map((item) => (
<div key={item.label} className="space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-caption text-ink-2">{item.label}</span>
<span className="flex shrink-0 items-baseline gap-1.5">
{item.weight != null && <span className="text-[10px] text-ink-4">{item.weight}%</span>}
<span className="text-caption font-semibold tabular-nums text-ink-1">{item.value}</span>
</span>
</div>
<ProgressBar value={item.value} max={item.max ?? 100} tone={item.tone ?? 'score'} size="sm" />
</div>
))}
</div>
));
MetersBlock.displayName = 'MetersBlock';
/**
* A table. Scrolls horizontally inside its own container so a wide comparison
* never widens the panel.
*/
const TableBlock = React.memo(({ block }) => {
const cell = (value) => {
if (value == null || value === '') return <span className="text-ink-4">—</span>;
if (typeof value === 'object') {
if (value.badge) {
return <Badge variant={TONE_BADGE[value.tone] || 'neutral'} size="sm">{value.badge}</Badge>;
}
return (
<span className={cn('tabular-nums', value.tone ? TONE_TEXT[value.tone] : undefined)}>
{value.value}
</span>
);
}
return <span className="tabular-nums">{value}</span>;
};
return (
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
<table className="w-full border-collapse text-caption">
{block.caption && <caption className="sr-only">{block.caption}</caption>}
<thead>
<tr className="border-b border-border bg-surface-subtle">
{block.columns.map((column) => (
<th
key={column.key}
scope="col"
className={cn(
'whitespace-nowrap px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
column.align === 'right' ? 'text-right' : 'text-left'
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={i} className="border-b border-border last:border-0">
{block.columns.map((column, ci) => (
<td
key={column.key}
className={cn(
'px-2.5 py-2 align-middle',
column.align === 'right' ? 'text-right' : 'text-left',
ci === 0 ? 'font-medium text-ink-1' : 'text-ink-2'
)}
>
{cell(row[column.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
});
TableBlock.displayName = 'TableBlock';
/** Stage progression, widths relative to the largest stage. */
const FunnelBlock = React.memo(({ block }) => {
const max = Math.max(...block.steps.map((s) => s.count), 1);
return (
<div className="space-y-2">
{block.steps.map((step) => (
<div key={step.label}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-caption text-ink-2">{step.label}</span>
<span className="flex shrink-0 items-baseline gap-1.5">
{step.rate != null && <span className="text-[10px] text-ink-4">{step.rate}%</span>}
<span className="text-caption font-semibold tabular-nums text-ink-1">{step.count}</span>
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-surface-sunken">
<div
className="h-full rounded-full bg-krow-blue transition-all duration-slow ease-out"
style={{ width: `${Math.max((step.count / max) * 100, step.count > 0 ? 4 : 0)}%` }}
/>
</div>
</div>
))}
</div>
);
});
FunnelBlock.displayName = 'FunnelBlock';
const ListBlock = React.memo(({ block }) => {
const Tag = block.ordered ? 'ol' : 'ul';
return (
<Tag className="space-y-1.5">
{block.items.map((item, i) => (
<li key={i} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
{block.ordered ? (
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{i + 1}
</span>
) : (
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
)}
<span className="min-w-0"><Inline value={item} /></span>
</li>
))}
</Tag>
);
});
ListBlock.displayName = 'ListBlock';
/** Findings, each in a tinted card so severity is scannable. */
const InsightsBlock = React.memo(({ block }) => (
<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>
))}
</div>
));
InsightsBlock.displayName = 'InsightsBlock';
/** Recommended steps — numbered, because order is the recommendation. */
const ActionsBlock = React.memo(({ block }) => (
<ol className="space-y-2">
{block.items.map((item, i) => (
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-[10px] font-bold text-white">
{i + 1}
</span>
<div className="min-w-0">
<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>
</li>
))}
</ol>
));
ActionsBlock.displayName = 'ActionsBlock';
const BadgesBlock = React.memo(({ block }) => (
<div className="flex flex-wrap gap-1.5">
{block.items.map((item) => (
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
{item.label}
</Badge>
))}
</div>
));
BadgesBlock.displayName = 'BadgesBlock';
const TimelineBlock = React.memo(({ block }) => (
<ol className="relative space-y-3">
{block.items.map((item, i) => (
<li key={i} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn(
'mt-1 h-2 w-2 shrink-0 rounded-full',
item.tone === 'success' ? 'bg-success'
: item.tone === 'warning' ? 'bg-warning'
: item.current ? 'bg-krow-blue' : 'bg-ink-4'
)}
aria-hidden="true"
/>
{i < block.items.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
</div>
<div className="min-w-0 flex-1 pb-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm font-medium text-ink-1">{item.title}</span>
{item.timestamp && (
<span className="shrink-0 text-[10px] text-ink-4">{item.timestamp}</span>
)}
</div>
{item.description && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.description}</p>
)}
</div>
</li>
))}
</ol>
));
TimelineBlock.displayName = 'TimelineBlock';
const NoteBlock = React.memo(({ block }) => (
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
<Inline value={block.text} />
</p>
));
NoteBlock.displayName = 'NoteBlock';
const RENDERERS = {
text: TextBlock,
heading: HeadingBlock,
kpis: KpisBlock,
status: StatusBlock,
meters: MetersBlock,
table: TableBlock,
funnel: FunnelBlock,
list: ListBlock,
insights: InsightsBlock,
actions: ActionsBlock,
badges: BadgesBlock,
timeline: TimelineBlock,
note: NoteBlock,
};
/**
* Renders a response document.
*
* Spacing is decided here rather than by the blocks, so any combination of
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(({ blocks = [], streaming = false }) => (
<div className="space-y-3">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];
if (!Renderer) return null;
const hugsNext = block.type === 'heading' && i < blocks.length - 1;
const isLast = i === blocks.length - 1;
return (
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
<Renderer block={block} />
{/* The cursor trails the final block only while text is still arriving. */}
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
<span
className="ml-0.5 inline-block h-3.5 w-1.5 -mb-0.5 animate-caret bg-krow-blue align-middle"
aria-hidden="true"
/>
)}
</div>
);
})}
</div>
));
ResponseDocument.displayName = 'ResponseDocument';
export { toneForScore };

View File

@@ -0,0 +1,100 @@
/**
* Response blocks.
*
* Answers are documents, not strings. A capability decides how its information
* is best communicated — a comparison is a table, a health check is a status
* list, a funnel is a funnel — and the renderer draws it with design-system
* components. That is what turns a reply into a small interactive report rather
* than a wall of chat text.
*
* Blocks are plain data, so they serialize: a future remote service can return
* exactly this shape and nothing in the UI changes.
*/
/** Assembles a document, flattening nested arrays and dropping empty slots. */
export const doc = (...blocks) => ({
blocks: blocks.flat(Infinity).filter(Boolean),
});
/** A short paragraph. Supports `**bold**` and `_italic_` inline. */
export const text = (value) => value && { type: 'text', text: value };
/** A section label inside a response. */
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
export const kpis = (items) => items?.length && { type: 'kpis', items };
/** Pass/warn checks. `items: [{ label, ok, value?, note? }]` */
export const status = (items) => items?.length && { type: 'status', items };
/** Labelled bars. `items: [{ label, value, weight?, max?, tone? }]` */
export const meters = (items) => items?.length && { type: 'meters', items };
/**
* A comparison or record table.
* `columns: [{ key, label, align?, width? }]`, `rows: [{ ...cells }]`
* A cell may be a string, a number, or `{ value, tone, badge }`.
*/
export const table = (columns, rows, options = {}) =>
rows?.length && { type: 'table', columns, rows, ...options };
/** Stage progression. `steps: [{ label, count, rate?, lost? }]` */
export const funnel = (steps) => steps?.length && { type: 'funnel', steps };
/** Bulleted or numbered points. */
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 */
export const insights = (items) =>
items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };
/** Recommended next steps. `items: [{ title, body }]` */
export const actions = (items) =>
items?.filter(Boolean).length && { type: 'actions', items: items.filter(Boolean) };
/** Inline tags. `items: [{ label, tone }]` or plain strings. */
export const badges = (items) =>
items?.filter(Boolean).length && {
type: 'badges',
items: items.filter(Boolean).map((i) => (typeof i === 'string' ? { label: i } : i)),
};
/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
export const timeline = (items) => items?.length && { type: 'timeline', items };
/** A caveat. Always the last word on a claim, never the headline. */
export const note = (value) => value && { type: 'note', text: value };
/** A single sentence answer — used by short free-text replies. */
export const answer = (value) => doc(text(value));
/* ── Streaming ──────────────────────────────────────────────────────────── */
/**
* Splits a document into the progressive snapshots the provider streams.
*
* Text and heading blocks reveal word by word; structured blocks appear whole,
* because a half-drawn table reads as broken rather than as arriving. The result
* is a response that composes itself in a readable order.
*/
export function toSnapshots({ blocks }) {
const snapshots = [];
const settled = [];
for (const block of blocks) {
if (block.type === 'text' || block.type === 'heading') {
const tokens = String(block.text).split(/(\s+)/);
// Three words per snapshot: fast enough to feel live, coarse enough to
// avoid a reflow per character.
for (let i = 6; i < tokens.length; i += 6) {
snapshots.push([...settled, { ...block, text: tokens.slice(0, i).join('') }]);
}
}
settled.push(block);
snapshots.push([...settled]);
}
return snapshots.length ? snapshots : [[]];
}

View File

@@ -1,11 +1,12 @@
import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
import { actions, answer, doc, heading, insights, kpis, note, status, table, text } from '../blocks';
import { plural, verb } from '../insights';
/**
* Admin capabilities — Control Center, Candidates Analysis, Activity.
*
* Admin answers are platform-wide rather than pipeline-specific: data integrity,
* talent supply, audit reconciliation.
* Platform-wide rather than pipeline-specific: data integrity, talent supply,
* audit reconciliation.
*/
/* ── Control Center ─────────────────────────────────────────────────────── */
@@ -15,148 +16,184 @@ const platformHealth = (f) => {
{
label: 'Data integrity',
ok: f.applications.every((a) => a.job_posting_id),
good: 'Every application is linked to a live posting.',
good: 'Every application links to a live posting.',
bad: 'Some applications reference a posting that no longer exists.',
},
{
label: 'Screening coverage',
value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80,
good: `${f.standardizedPct}% of applicants carry a score.`,
bad: `Only ${f.standardizedPct}% of applicants are scored — ${plural(f.unscreened.length, 'record')} unstandardized.`,
good: 'Applicants are scored consistently.',
bad: `${plural(f.unscreened.length, 'record')} unstandardized.`,
},
{
label: 'Interview integrity',
ok: f.flagged.length === 0,
good: 'No interviews flagged.',
bad: `${plural(f.flagged.length, 'interview')} flagged below full integrity.`,
bad: `${plural(f.flagged.length, 'interview')} below full integrity.`,
},
{
label: 'Profile completeness',
value: `${f.profiles.length - f.unscoredProfiles.length}/${f.profiles.length}`,
ok: f.unscoredProfiles.length <= f.profiles.length / 2,
good: `${f.profiles.length - f.unscoredProfiles.length} of ${f.profiles.length} talent profiles have a career score.`,
bad: `${plural(f.unscoredProfiles.length, 'profile')} of ${f.profiles.length} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score, so ${verb(f.unscoredProfiles.length, 'it cannot', 'they cannot')} be matched or ranked.`,
good: 'Most talent profiles carry a career score.',
bad: `${plural(f.unscoredProfiles.length, 'profile')} without a score cannot be matched or ranked.`,
},
{
label: 'Throughput',
value: `${f.hireRate}%`,
ok: f.hired.length > 0,
good: `${plural(f.hired.length, 'hire')} closed at a ${f.hireRate}% hire rate.`,
good: `${plural(f.hired.length, 'hire')} closed.`,
bad: 'No hires closed yet, so there is no conversion baseline.',
},
];
].map((c) => ({ label: c.label, value: c.value, ok: c.ok, note: c.ok ? c.good : c.bad }));
const passing = checks.filter((c) => c.ok).length;
return compose(
heading(`Platform health: ${passing}/${checks.length} checks passing`),
'',
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.ok ? c.good : c.bad}`).join('\n'),
'',
return doc(
heading(`Platform health: ${passing}/${checks.length} passing`),
status(checks),
heading('Scale'),
bullets([
`${plural(f.postings.length, 'position')}, ${f.openPositions.length} open`,
`${plural(f.total, 'application')}`,
`${plural(f.interviews.length, 'AI interview')}`,
`${plural(f.profiles.length, 'talent profile')}, ${f.elite.length} Elite`,
`${plural(f.activity.length, 'logged event')}`,
kpis([
{ label: 'Positions', value: f.postings.length, sub: `${f.openPositions.length} open` },
{ label: 'Applications', value: f.total },
{ label: 'Interviews', value: f.interviews.length, sub: `${f.completedInterviews.length} scored` },
{ label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` },
]),
'',
passing === checks.length
? 'Nothing needs intervention at the platform level.'
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
text(
passing === checks.length
? 'Nothing needs intervention at the platform level.'
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
)
);
};
const recommendations = (f) => {
const actions = [];
const items = [
f.unscoredProfiles.length && {
title: `Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding`,
body: 'Without a career score they are invisible to matching, so employers cannot find them even when they fit.',
},
f.unscreened.length && {
title: `${plural(f.unscreened.length, 'application')} unscored`,
body: 'Each one is a decision made without a standard.',
},
f.starvedPositions.length && {
title: `${plural(f.starvedPositions.length, 'posting')} with zero applicants`,
body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`,
},
f.unratedStaff.length && {
title: `${plural(f.unratedStaff.length, 'hire')} unrated`,
body: 'Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.',
},
f.flagged.length && {
title: `Review ${plural(f.flagged.length, 'flagged interview')}`,
body: 'Worth clearing before those candidates progress.',
},
].filter(Boolean);
if (f.unscoredProfiles.length) {
actions.push(`**Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding.** Without a career score they are invisible to matching, so employers cannot find them even when they fit.`);
}
if (f.unscreened.length) {
actions.push(`**${plural(f.unscreened.length, 'application')} unscored.** Each one is a decision made without a standard.`);
}
if (f.starvedPositions.length) {
actions.push(`**${plural(f.starvedPositions.length, 'posting')} with zero applicants** — ${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`);
}
if (f.unratedStaff.length) {
actions.push(`**${plural(f.unratedStaff.length, 'hire')} unrated.** Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.`);
}
if (f.flagged.length) {
actions.push(`**Review ${plural(f.flagged.length, 'flagged interview')}** before those candidates progress.`);
}
if (!items.length) return answer('No platform actions outstanding. Data is complete and nothing is flagged.');
if (!actions.length) return 'No platform actions outstanding. Data is complete and nothing is flagged.';
return compose(heading('Recommended actions'), '', numbered(actions));
return doc(heading('Recommended actions', 'Ordered by impact on matching quality'), actions(items));
};
/* ── Candidates Analysis ────────────────────────────────────────────────── */
const recruitmentInsights = (f) => {
const bands = {
Elite: f.profiles.filter((p) => (p.krow_score || 0) >= 90).length,
Excellent: f.profiles.filter((p) => (p.krow_score || 0) >= 75 && (p.krow_score || 0) < 90).length,
Solid: f.profiles.filter((p) => (p.krow_score || 0) >= 60 && (p.krow_score || 0) < 75).length,
Building: f.profiles.filter((p) => (p.krow_score || 0) > 0 && (p.krow_score || 0) < 60).length,
Unscored: f.unscoredProfiles.length,
};
const band = (min, max) => f.profiles.filter((p) => {
const s = p.krow_score || 0;
return s >= min && (max == null || s < max);
}).length;
const bands = [
{ label: 'Elite', count: band(90), tone: 'info' },
{ label: 'Excellent', count: band(75, 90), tone: 'success' },
{ label: 'Solid', count: band(60, 75) },
{ label: 'Building', count: band(1, 60), tone: 'warning' },
{ label: 'Unscored', count: f.unscoredProfiles.length, tone: 'risk' },
];
const verified = f.profiles.filter((p) => (p.capabilities || []).length).length;
const topHeavy = bands.Elite + bands.Excellent;
const topHeavy = band(90) + band(75, 90);
return compose(
heading('Talent supply'),
'',
bullets(Object.entries(bands).map(([band, count]) => `${band} — ${count}`)),
'',
return doc(
heading('Talent supply', `${plural(f.profiles.length, 'worker')} in the pool`),
table(
[{ key: 'label', label: 'Band' }, { key: 'count', label: 'Workers', align: 'right' }],
bands.map((b) => ({ label: b.label, count: { value: b.count, tone: b.tone } }))
),
heading('Pipeline'),
bullets([
`${plural(f.total, 'applicant')} averaging ${f.avgScore}`,
`${plural(f.ranked.filter((a) => a.ai_score >= 80).length, 'candidate')} at 80+`,
`${plural(verified, 'worker')} with at least one verified capability`,
`${plural(f.eventCounts.apply_job || 0, 'application event')} logged`,
kpis([
{ label: 'Applicants', value: f.total, sub: `avg ${f.avgScore}` },
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'success' },
{ label: 'Verified skills', value: verified, sub: 'with evidence' },
{ label: 'Applications', value: f.eventCounts.apply_job || 0, sub: 'logged' },
]),
insights([
f.unscoredProfiles.length > topHeavy
? {
tone: 'warning',
title: 'The pool is bottom-heavy',
body: `${f.unscoredProfiles.length} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`,
}
: {
tone: 'success',
title: 'Verification is keeping pace',
body: 'The scored portion of the pool outweighs the unscored.',
},
]),
'',
bands.Unscored > topHeavy
? `The pool is bottom-heavy: ${bands.Unscored} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`
: 'The scored portion outweighs the unscored, so verification is keeping pace with sign-ups.',
'',
heading('By role category'),
bullets(
table(
[
{ key: 'title', label: 'Role' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'qualified', label: '70+', align: 'right' },
],
f.byRole.slice().sort((a, b) => b.applied - a.applied)
.map((r) => `${r.title} — ${plural(r.applied, 'applicant')}, ${r.qualified} at 70+`)
.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified }))
)
);
};
const candidateRiskAnalysis = (f) => {
const risks = [];
if (f.missingCredentials.length) {
risks.push(`**No credentials on file** — ${plural(f.missingCredentials.length, 'candidate')}. Compliance risk if placed on a role that requires certification.`);
}
if (f.narrowAvailability.length) {
risks.push(`**Narrow availability** — ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. Most likely to fall through on the day.`);
}
if (f.flagged.length) {
risks.push(`**Interview integrity** — ${plural(f.flagged.length, 'interview')} below full integrity, usually suspiciously fast responses. Worth a human re-interview.`);
}
const noHistory = f.scored.filter((a) => !a.years_experience);
if (noHistory.length) {
risks.push(`**No recorded experience** — ${plural(noHistory.length, 'candidate')}. Not disqualifying, but unverifiable on paper.`);
}
if (f.singleCandidateRoles.length) {
risks.push(`**Concentration risk** — ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`);
const risks = [
f.missingCredentials.length && {
tone: 'risk',
title: `${plural(f.missingCredentials.length, 'candidate')} with no credentials`,
body: 'Compliance risk if placed on a role that requires certification.',
},
f.narrowAvailability.length && {
tone: 'warning',
title: `${plural(f.narrowAvailability.length, 'candidate')} with narrow availability`,
body: 'One slot or none — most likely to fall through on the day.',
},
f.flagged.length && {
tone: 'risk',
title: `${plural(f.flagged.length, 'interview')} flagged for integrity`,
body: 'Usually suspiciously fast responses. Worth a human re-interview.',
},
noHistory.length && {
tone: 'warning',
title: `${plural(noHistory.length, 'candidate')} with no recorded experience`,
body: 'Not disqualifying, but unverifiable on paper.',
},
f.singleCandidateRoles.length && {
tone: 'warning',
title: 'Concentration risk',
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`,
},
].filter(Boolean);
if (!risks.length) {
return answer('No material risk flags: credentials, availability and interview integrity all check out across the scored pool.');
}
if (!risks.length) return 'No material risk flags: credentials, availability and interview integrity all check out across the scored pool.';
return compose(
return doc(
heading(`${plural(risks.length, 'risk flag')}`),
'',
numbered(risks),
'',
'_Flags to check, not judgements. Each is a question for a human, not a rejection._'
insights(risks),
note('Flags to check, not judgements. Each is a question for a human, not a rejection.')
);
};
@@ -166,36 +203,45 @@ const auditSummary = (f) => {
const count = (type) => f.activity.filter((e) => e.event_type === type).length;
const hires = count('hire_candidate');
const screens = count('screen_candidate');
const creates = count('create_position');
return compose(
return doc(
heading('Audit summary'),
'',
bullets([
`${plural(f.activity.length, 'event')} total`,
`${plural(f.activity7d.length, 'event')} in the last 7 days`,
`${plural(f.activity24h.length, 'event')} in the last 24 hours`,
`${plural(new Set(f.activity.map((e) => e.user_email)).size, 'distinct user')}`,
kpis([
{ label: 'Total events', value: f.activity.length },
{ label: 'Last 7 days', value: f.activity7d.length },
{ label: 'Last 24 hours', value: f.activity24h.length, tone: f.activity24h.length ? undefined : 'warning' },
{ label: 'Distinct users', value: new Set(f.activity.map((e) => e.user_email)).size },
]),
'',
heading('Privileged actions'),
bullets([
`${plural(creates, 'position creation')}`,
`${plural(screens, 'screening action')}`,
`${plural(hires, 'hire')}`,
]),
'',
table(
[{ key: 'action', label: 'Action' }, { key: 'count', label: 'Events', align: 'right' }],
[
{ action: 'Position created', count: count('create_position') },
{ action: 'Candidate screened', count: screens },
{ action: 'Candidate hired', count: hires },
{ action: 'Interview started', count: count('start_interview') },
]
),
heading('Reconciliation'),
bullets([
hires === f.staff.length
? `All ${f.staff.length} people on staff have a matching hire event.`
: `${f.staff.length} on staff but ${hires} hire ${verb(hires, 'event', 'events')} — ${Math.abs(f.staff.length - hires)} unexplained.`,
screens < f.scored.length
? `${f.scored.length} candidates carry scores but only ${plural(screens, 'screening event')} logged. Batch screening records one event per run, not per candidate.`
: 'Screening events reconcile with scored candidates.',
status([
{
label: 'Hires match staff records',
ok: hires === f.staff.length,
value: `${hires}/${f.staff.length}`,
note: hires === f.staff.length
? 'Every person on staff has a matching hire event.'
: `${Math.abs(f.staff.length - hires)} unexplained.`,
},
{
label: 'Screening events match scores',
ok: screens >= f.scored.length,
value: `${screens}/${f.scored.length}`,
note: screens < f.scored.length
? 'Batch screening records one event per run, not per candidate.'
: 'Screening events reconcile with scored candidates.',
},
]),
'',
'_An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review._'
note('An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review.')
);
};
@@ -208,32 +254,43 @@ const userActivityInsights = (f) => {
}, {});
const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count);
if (!ranked.length) return 'No activity to profile yet.';
const byRole = f.activity.reduce((acc, e) => {
const role = e.account_type || 'unknown';
acc[role] = (acc[role] || 0) + 1;
return acc;
}, {});
if (!ranked.length) return answer('No activity to profile yet.');
const [topEmail, top] = ranked[0];
const share = Math.round((top.count / f.activity.length) * 100);
return compose(
return doc(
heading('Most active accounts'),
'',
bullets(ranked.slice(0, 6).map(([email, d]) =>
`**${d.name || email}** (${d.role || 'unknown'}) — ${plural(d.count, 'event')} across ${plural(d.types.size, 'action type')}`
)),
'',
heading('By role'),
bullets(Object.entries(byRole).sort((a, b) => b[1] - a[1])
.map(([role, c]) => `${role.charAt(0).toUpperCase() + role.slice(1)} — ${plural(c, 'event')}`)),
'',
`${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, one account dominating the log is worth confirming is a person and not an integration.`,
'',
f.activity24h.length === 0
? 'No activity in the last day. Expected for a demo dataset; on a live platform it would be worth checking ingestion.'
: 'Activity is flowing normally.'
table(
[
{ key: 'name', label: 'Account' },
{ key: 'role', label: 'Role' },
{ key: 'events', label: 'Events', align: 'right' },
],
ranked.slice(0, 6).map(([email, d]) => ({
name: d.name || email,
role: { badge: d.role || 'unknown', tone: d.role === 'employer' ? 'info' : 'neutral' },
events: { value: d.count, tone: d.count === top.count ? 'info' : undefined },
}))
),
insights([
share >= 50
? {
tone: 'warning',
title: `One account is ${share}% of all activity`,
body: `${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, that concentration is worth confirming is a person and not an integration.`,
}
: {
tone: 'success',
title: 'Activity is spread across accounts',
body: `No single account dominates the log — the busiest is ${share}%.`,
},
f.activity24h.length === 0 && {
tone: 'warning',
title: 'Nothing in the last 24 hours',
body: 'Expected for a demo dataset; on a live platform it would be worth checking ingestion.',
},
])
);
};
@@ -244,6 +301,7 @@ 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);
return platformHealth(f);
};
@@ -255,7 +313,7 @@ export const respondAdminCandidates = (question, f) => {
export const respondActivity = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account')) return userActivityInsights(f);
if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account', 'unusual')) return userActivityInsights(f);
return auditSummary(f);
};

View File

@@ -1,36 +1,47 @@
import {
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers,
HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
} from 'lucide-react';
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
import {
actions, answer, badges, doc, funnel, heading, insights, kpis, list, meters, note,
status, table, text,
} from '../blocks';
import { plural, verb } from '../insights';
/**
* Employer capabilities — Overview, Candidates, Analytics.
*
* Each capability is a pure function of the fact sheet. Keeping them pure is
* what allows the same code to run against local data today and against a
* remote AI service later without the UI changing.
* Each returns a block document rather than prose. The shape is chosen by what
* the information is: a comparison is a table, a health check is a status list,
* a pipeline is a funnel. Prose is reserved for the judgement a table cannot
* carry.
*/
/* ── Overview ───────────────────────────────────────────────────────────── */
const hiringSummary = (f) => {
if (!f.total) return 'Nothing in the pipeline yet. Publish a position and I will start tracking it here.';
if (!f.total) return answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.');
return compose(
heading('Where hiring stands'),
bullets([
`${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}`,
`${f.screened.length} screened · ${f.unscreened.length} awaiting a score`,
`${plural(f.interviewing.length, 'candidate')} mid-interview`,
`${plural(f.hired.length, 'hire')} averaging ${f.hiredAvgScore}/100`,
return doc(
kpis([
{ label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` },
{ label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'Interviewing', value: f.interviewing.length },
{ label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' },
]),
'',
f.stalled.length
? `Most urgent: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened.`
: f.unscreened.length
? `Most urgent: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
heading('Pipeline'),
funnel(f.funnel.map((stage, i) => ({
label: stage.label,
count: stage.count,
rate: i === 0 ? undefined : f.transitions[i - 1]?.rate,
}))),
text(
f.stalled.length
? `The first thing I would fix: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still at AI Screened.`
: f.unscreened.length
? `The first thing I would fix: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
)
);
};
@@ -41,7 +52,7 @@ const hiringHealth = (f) => {
value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80,
note: f.standardizedPct >= 80
? 'Applicants are being scored consistently.'
? 'Applicants are scored consistently.'
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
},
{
@@ -72,148 +83,201 @@ const hiringHealth = (f) => {
const passing = checks.filter((c) => c.ok).length;
return compose(
return doc(
heading(`${passing} of ${checks.length} signals healthy`),
'',
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n')
status(checks),
note('Thresholds are event-staffing norms, not universal benchmarks.')
);
};
const buildActions = (f) => [
f.stalled.length && {
title: 'Move your 80+ candidates',
body: `${f.stalled.slice(0, 3).map((a) => `**${a.applicant_name}** (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`,
},
f.unscreened.length && {
title: `Screen ${plural(f.unscreened.length, 'applicant')}`,
body: 'One pass clears the backlog and costs nothing.',
},
f.starvedPositions.length && {
title: 'Fix the postings nobody applies to',
body: `${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`,
},
f.unratedStaff.length && {
title: `Rate ${plural(f.unratedStaff.length, 'hire')}`,
body: 'Ratings feed KROW scores and sharpen future matching.',
},
f.elite.length && {
title: 'Reach out to the pool directly',
body: `${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`,
},
].filter(Boolean);
const pendingActions = (f) => {
const actions = [];
if (f.stalled.length) {
actions.push(`**Move your 80+ candidates.** ${f.stalled.slice(0, 3).map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`);
const items = buildActions(f);
if (!items.length) {
return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
}
if (f.unscreened.length) {
actions.push(`**Screen the backlog.** ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} no score. One pass clears it.`);
}
if (f.starvedPositions.length) {
actions.push(`**Fix the postings nobody applies to.** ${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`);
}
if (f.unratedStaff.length) {
actions.push(`**Rate your hires.** ${plural(f.unratedStaff.length, 'person', 'people')} ${verb(f.unratedStaff.length, 'has', 'have')} no rating. Ratings sharpen future matching.`);
}
if (f.elite.length) {
actions.push(`**Reach out directly.** ${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`);
}
if (!actions.length) return 'Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.';
return compose(heading('Pending actions'), '', numbered(actions.slice(0, 4)));
return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
};
const hiringRisks = (f) => {
const risks = [];
const risks = [
f.singleCandidateRoles.length && {
tone: 'risk',
title: 'Single-candidate roles',
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`,
},
f.stalled.length && {
tone: 'warning',
title: 'Losing strong candidates to delay',
body: `${plural(f.stalled.length, 'candidate')} at 80+ screened but not moved. This is where good hires quietly disappear.`,
},
f.missingCredentials.length && {
tone: 'risk',
title: 'Compliance exposure',
body: `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`,
},
f.narrowAvailability.length && {
tone: 'warning',
title: 'Availability risk',
body: `${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none — most likely to fall through on the day.`,
},
f.starvedPositions.length && {
tone: 'warning',
title: 'Unfillable as posted',
body: `${plural(f.starvedPositions.length, 'open role')} with zero applicants after going live.`,
},
f.flagged.length && {
tone: 'risk',
title: 'Interview integrity',
body: `${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`,
},
].filter(Boolean);
if (f.singleCandidateRoles.length) {
risks.push(`**Single-candidate roles.** ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`);
}
if (f.stalled.length) {
risks.push(`**Losing strong candidates to delay.** ${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'has', 'have')} been screened but not moved. This is where good hires quietly disappear.`);
}
if (f.missingCredentials.length) {
risks.push(`**Compliance exposure.** ${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`);
}
if (f.narrowAvailability.length) {
risks.push(`**Availability risk.** ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. ${verb(f.narrowAvailability.length, 'This is the placement', 'These are the placements')} most likely to fall through on the day.`);
}
if (f.starvedPositions.length) {
risks.push(`**Unfillable as posted.** ${plural(f.starvedPositions.length, 'open role')} with zero applicants after being live.`);
}
if (f.flagged.length) {
risks.push(`**Interview integrity.** ${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`);
if (!risks.length) {
return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
}
if (!risks.length) return 'No material risks in the current pipeline: coverage is good, no role depends on a single candidate, and credentials check out.';
return compose(
return doc(
heading(`${plural(risks.length, 'risk')} to watch`),
'',
numbered(risks),
'',
'_Flags to check, not conclusions. Each is a question for a human._'
insights(risks),
note('Flags to check, not conclusions. Each is a question for a human.')
);
};
/* ── Candidates ─────────────────────────────────────────────────────────── */
const resumeAnalysis = (f) => {
const subject = f.top[0];
if (!subject) return 'No scored candidates yet. Screen the pipeline and I will analyse the strongest.';
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
return compose(
heading(`${subject.applicant_name} — ${subject.ai_score}/100`),
`${subject.job_title} · ${plural(subject.years_experience, 'year')} experience · ${subject.english_level} English`,
'',
subject.ai_summary || '',
'',
subject.ai_strengths?.length ? compose(heading('What holds up'), bullets(subject.ai_strengths)) : '',
'',
subject.ai_gaps?.length ? compose(heading('What to probe'), bullets(subject.ai_gaps)) : '',
'',
subject.certifications?.length
? `**Credentials:** ${subject.certifications.join(', ')} — verify before the first shift. A lapsed card is the most common surprise.`
: '**No credentials on file.** Request these before scheduling.'
const resumeAnalysis = (f) => {
const c = f.top[0];
if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.');
return doc(
heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`),
kpis([
{ label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' },
{ label: 'Verdict', value: c.ai_match_label || '—' },
]),
c.ai_summary && text(c.ai_summary),
c.certifications?.length
? [heading('Credentials'), badges(c.certifications.map((label) => ({ label, tone: 'success' })))]
: [heading('Credentials'), badges([{ label: 'None on file', tone: 'risk' }])],
heading('Score breakdown'),
meters(DIMENSIONS.map((d) => ({ label: d[0].toUpperCase() + d.slice(1), value: c.score_breakdown?.[d] ?? 0 }))),
c.ai_strengths?.length && [heading('Strengths'), list(c.ai_strengths)],
c.ai_gaps?.length && [heading('Probe these'), list(c.ai_gaps)],
note(
c.certifications?.length
? 'Verify credentials before the first shift — a lapsed card is the most common surprise.'
: 'Request credentials before scheduling.'
)
);
};
const candidateComparison = (f) => {
const pair = f.ranked.slice(0, 2);
if (pair.length < 2) return 'I need at least two scored candidates to compare.';
const [a, b] = f.ranked;
if (!a || !b) return answer('I need at least two scored candidates to compare.');
const [a, b] = pair;
const gap = a.ai_score - b.ai_score;
const dims = ['experience', 'english', 'reliability', 'certifications', 'availability'];
const first = (n) => n.split(' ')[0];
const rows = dims.map((dim) => {
const av = a.score_breakdown?.[dim] ?? 0;
const bv = b.score_breakdown?.[dim] ?? 0;
const lead = av === bv ? 'tied' : (av > bv ? a : b).applicant_name.split(' ')[0];
return `${dim.charAt(0).toUpperCase() + dim.slice(1)} — ${av} vs ${bv} · ${lead}`;
const rows = DIMENSIONS.map((d) => {
const av = a.score_breakdown?.[d] ?? 0;
const bv = b.score_breakdown?.[d] ?? 0;
return {
dimension: d[0].toUpperCase() + d.slice(1),
a: { value: av, tone: av > bv ? 'success' : av < bv ? 'neutral' : undefined },
b: { value: bv, tone: bv > av ? 'success' : bv < av ? 'neutral' : undefined },
};
});
const biggest = dims.reduce((best, d) =>
const biggest = DIMENSIONS.reduce((best, d) =>
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, dims[0]);
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, DIMENSIONS[0]);
return compose(
heading(`${a.applicant_name} (${a.ai_score}) vs ${b.applicant_name} (${b.ai_score})`),
'',
bullets(rows),
'',
gap <= 3
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
: `${a.applicant_name} leads by ${gap}, driven mostly by ${biggest}.`
return doc(
heading(`${a.applicant_name} vs ${b.applicant_name}`),
table(
[
{ key: 'dimension', label: '' },
{ key: 'a', label: first(a.applicant_name), align: 'right' },
{ key: 'b', label: first(b.applicant_name), align: 'right' },
],
[
{ dimension: 'Overall', a: { value: a.ai_score, tone: 'info' }, b: { value: b.ai_score, tone: 'info' } },
...rows,
],
{ caption: 'Score comparison by dimension' }
),
heading('Where each one wins'),
insights([
{
tone: 'success',
title: `${first(a.applicant_name)} leads on ${biggest}`,
body: a.ai_strengths?.[0] || `Scores ${a.score_breakdown?.[biggest] ?? 0} against ${b.score_breakdown?.[biggest] ?? 0}.`,
},
b.ai_strengths?.[0] && {
tone: 'info',
title: `${first(b.applicant_name)} brings`,
body: b.ai_strengths[0],
},
]),
text(
gap <= 3
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
: `${a.applicant_name} leads by ${gap} points, driven mostly by ${biggest}.`
),
note('Recommendation only. Neither has been met in person.')
);
};
const interviewQuestions = (f) => {
const subject = f.stalled[0] || f.ranked[0];
if (!subject) return 'Screen a candidate and I will build questions around their specific gaps.';
const c = f.stalled[0] || f.ranked[0];
if (!c) return answer('Screen a candidate and I will build questions around their specific gaps.');
const gaps = subject.ai_gaps || [];
const gaps = c.ai_gaps || [];
return compose(
heading(`Questions for ${subject.applicant_name}`),
'Built from their gaps rather than a generic list, so the interview tests what the score could not.',
'',
numbered([
`Walk me through your busiest ${subject.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
return doc(
heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'),
list([
`Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
gaps[0]
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
: 'Tell me about a shift that went wrong. What was your part in it?',
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
subject.certifications?.length
? `Your ${subject.certifications[0]} is on file — when did you last apply it on a live shift?`
c.certifications?.length
? `Your ${c.certifications[0]} is on file — when did you last apply it on a live shift?`
: 'Which certifications are you working toward, and why that one?',
'What does your real availability look like over the next eight weeks?',
])
], { ordered: true }),
note('The scenario question is the one that separates candidates. Let them think.')
);
};
const skillGapAnalysis = (f) => {
if (!f.scored.length) return 'No scored candidates yet, so there are no gaps to analyse.';
if (!f.scored.length) return answer('No scored candidates yet, so there are no gaps to analyse.');
const tally = {};
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
@@ -222,54 +286,86 @@ const skillGapAnalysis = (f) => {
}));
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
if (!ranked.length) return 'No recurring gaps across your scored candidates — the pool matches your postings well.';
if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.');
return compose(
heading('Recurring gaps'),
'',
bullets(ranked.map(([gap, count]) => `${gap} — ${count} of ${f.scored.length}`)),
'',
f.missingCredentials.length >= 2
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
return doc(
heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`),
table(
[{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }],
ranked.map(([gap, count]) => ({
gap,
count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined },
}))
),
text(
f.missingCredentials.length >= 2
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
)
);
};
const hiringRecommendation = (f) => {
if (!f.scored.length) return 'Nothing scored yet, so I have no basis for a recommendation.';
if (!f.scored.length) return answer('Nothing scored yet, so I have no basis for a recommendation.');
const open = (a) => !['hired', 'rejected'].includes(a.status);
const shortlist = f.ranked.filter((a) => a.ai_score >= 80 && open(a));
const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a));
const pass = f.weak.filter(open);
const groups = [
{ verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) },
{ verdict: 'Interview', tone: 'info', people: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)) },
{ verdict: 'Likely pass', tone: 'risk', people: f.weak.filter(open) },
].filter((g) => g.people.length);
return compose(
if (!groups.length) return answer('Every scored candidate has already been actioned.');
return doc(
heading('Recommendation'),
'',
shortlist.length
? `**Shortlist** — ${shortlist.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}`
: '**Shortlist** — nobody currently clears 80.',
'',
interview.length ? `**Interview** — ${interview.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
'',
pass.length ? `**Likely pass** — ${pass.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
'',
'_A recommendation, not a decision. I score what is on file — I have not met these people._'
table(
[
{ key: 'name', label: 'Candidate' },
{ key: 'score', label: 'Score', align: 'right' },
{ key: 'verdict', label: 'Verdict', align: 'right' },
],
groups.flatMap((g) => g.people.map((p) => ({
name: p.applicant_name,
score: { value: p.ai_score, tone: g.tone },
verdict: { badge: g.verdict, tone: g.tone },
})))
),
note('A recommendation, not a decision. I score what is on file — I have not met these people.')
);
};
/* ── Analytics ──────────────────────────────────────────────────────────── */
const explainCharts = (f) => compose(
const explainCharts = (f) => doc(
heading('What these charts are saying'),
'',
`**Pipeline Stages** — ${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
'',
`**Candidate Scores** — ${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length}; that bucket is your real hiring pool.`,
'',
`**Interview Completion** — ${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
'',
`**Cost Saved** — $${f.costSaved.toLocaleString()}, from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80. Industry per-unit costs for doing it manually, so treat it as a floor.`
insights([
{
tone: 'info',
title: 'Pipeline Stages',
body: `${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
},
{
tone: 'info',
title: 'Candidate Scores',
body: `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length} — that bucket is your real hiring pool.`,
},
{
tone: f.interviewCompletion >= 70 ? 'success' : 'warning',
title: 'Interview Completion',
body: `${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
},
{
tone: 'success',
title: 'Cost Saved',
body: `$${f.costSaved.toLocaleString()} from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80 — industry per-unit costs for doing it manually, so treat it as a floor.`,
},
]),
f.bottleneck && [
heading('Biggest drop-off'),
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
text(`Most people are lost between **${f.bottleneck.from}** and **${f.bottleneck.to}** — only ${f.bottleneck.rate}% pass through, costing ${plural(f.bottleneck.lost, 'candidate')}.`),
]
);
const forecastHiring = (f) => {
@@ -278,124 +374,150 @@ const forecastHiring = (f) => {
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
const short = f.openPositions.length - expected;
return compose(
return doc(
heading('Forecast'),
'',
bullets([
`${plural(available.length, 'candidate')} at 60+ still in play`,
`At your ${conversion}% hire rate that projects to about ${plural(expected, 'hire')}`,
f.timeToHire ? `At a ${f.timeToHire}-day average, those land within a week of you deciding` : 'No time-to-hire baseline yet',
`${plural(f.openPositions.length, 'open position')} to fill`,
kpis([
{ label: 'In play at 60+', value: available.length },
{ label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' },
{ label: 'Open roles', value: f.openPositions.length },
{ label: 'Conversion', value: `${conversion}%` },
]),
'',
short > 0
? `That leaves you roughly ${plural(short, 'role')} short. Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`
: 'That covers your open roles, assuming the strong candidates do not go elsewhere first.',
'',
'_Projected from your own conversion rate, not a promise — small pools move a lot._'
? insights([{
tone: 'warning',
title: `Roughly ${plural(short, 'role')} short`,
body: `Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`,
}])
: insights([{
tone: 'success',
title: 'Current pool covers your open roles',
body: 'Assuming the strong candidates do not go elsewhere first.',
}]),
note('Projected from your own conversion rate, not a promise — small pools move a lot.')
);
};
const departmentInsights = (f) => {
if (!f.byRole.length) return 'No open positions to break down.';
if (!f.byRole.length) return answer('No open positions to break down.');
const byCategory = {};
f.byRole.forEach((r) => {
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: [] };
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: 0 };
byCategory[r.category].applied += r.applied;
byCategory[r.category].hired += r.hired;
byCategory[r.category].qualified += r.qualified;
byCategory[r.category].roles.push(r.title);
byCategory[r.category].roles += 1;
});
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
const starved = entries.filter(([, d]) => d.applied === 0);
return compose(
return doc(
heading('By role category'),
'',
entries.map(([category, d]) =>
`**${category}** — ${plural(d.applied, 'applicant')}, ${d.qualified} at 70+, ${plural(d.hired, 'hire')} across ${plural(d.roles.length, 'role')}`
).join('\n'),
'',
starved.length
? `${starved.map(([c]) => c).join(' and ')} ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
: 'Every category has applicants, so your reach is working across the board.'
table(
[
{ key: 'category', label: 'Category' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'qualified', label: '70+', align: 'right' },
{ key: 'hired', label: 'Hired', align: 'right' },
],
entries.map(([category, d]) => ({
category,
applied: { value: d.applied, tone: d.applied === 0 ? 'risk' : undefined },
qualified: d.qualified,
hired: d.hired,
}))
),
text(
starved.length
? `**${starved.map(([c]) => c).join(' and ')}** ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
: 'Every category has applicants, so your reach is working across the board.'
)
);
};
const generateReports = (f) => {
const stamp = f.today.toISOString().slice(0, 10);
return compose(
heading(`Hiring report — ${stamp}`),
'',
heading('Volume'),
bullets([
`${plural(f.total, 'applicant')} received`,
`${f.screened.length} screened (${f.standardizedPct}% coverage)`,
`${plural(f.interviews.length, 'AI interview')} conducted, ${f.completedInterviews.length} scored`,
`${plural(f.hired.length, 'hire')} closed`,
]),
'',
heading('Quality'),
bullets([
`Average candidate score ${f.avgScore}`,
`Average hire score ${f.hiredAvgScore}`,
`Hire rate ${f.hireRate}%`,
`Average time-to-hire ${f.timeToHire ? `${f.timeToHire} days` : 'no baseline'}`,
]),
'',
heading('Efficiency'),
bullets([
`$${f.costSaved.toLocaleString()} saved against manual screening and interviewing`,
`≈${f.hoursSaved} recruiter hours returned`,
]),
'',
heading('By role'),
bullets(f.byRole.map((r) => `${r.title} — ${r.applied} applied, ${r.qualified} at 70+, ${r.hired} hired`)),
'',
const generateReports = (f) => doc(
// Local date, not toISOString — UTC would show yesterday for anyone behind it.
heading('Hiring report', f.today.toLocaleDateString(undefined, {
year: 'numeric', month: 'long', day: 'numeric',
})),
kpis([
{ label: 'Applicants', value: f.total },
{ label: 'Screened', value: `${f.standardizedPct}%` },
{ label: 'Hires', value: f.hired.length },
{ label: 'Hire rate', value: `${f.hireRate}%` },
{ label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' },
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
]),
heading('Funnel'),
funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))),
heading('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' },
],
f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired }))
),
heading('Efficiency'),
kpis([
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
{ label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' },
]),
heading('Headline finding'),
insights([
f.bottleneck
? `**Headline finding.** The ${f.bottleneck.from} → ${f.bottleneck.to} step passes only ${f.bottleneck.rate}%, losing ${plural(f.bottleneck.lost, 'candidate')}. That is the single biggest recoverable loss in the funnel.`
: '**Headline finding.** The funnel is passing through evenly with no single bottleneck.',
'',
'_Copy this into your own template — export to PDF and CSV is not wired up in this build._'
);
};
? {
tone: 'warning',
title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`,
body: `Losing ${plural(f.bottleneck.lost, 'candidate')} — the single biggest recoverable loss in the funnel.`,
}
: { tone: 'success', title: 'No single bottleneck', body: 'The funnel is passing through evenly.' },
]),
buildActions(f).length && [heading('Recommended actions'), actions(buildActions(f).slice(0, 3))],
note('Copy this into your own template — PDF and CSV export are not wired up in this build.')
);
/* ── Free-text responders ───────────────────────────────────────────────── */
const has = (q, ...words) => words.some((w) => q.includes(w));
const stateOfPlay = (f) =>
`I do not have a specific read on that. What I can see: ${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}, ${f.screened.length} screened, ${plural(f.interviewing.length, 'candidate')} interviewing, ${plural(f.hired.length, 'hire')}. Ask about any of those and I will go deeper.`;
const stateOfPlay = (f) => doc(
text('I do not have a specific read on that. Here is where things stand:'),
kpis([
{ label: 'Applicants', value: f.total },
{ label: 'Screened', value: f.screened.length },
{ label: 'Interviewing', value: f.interviewing.length },
{ label: 'Hired', value: f.hired.length },
]),
text('Ask me about any of those and I will go deeper.')
);
const bottleneckAnswer = (f) => {
if (!f.bottleneck) return 'Not enough pipeline movement yet to locate a bottleneck.';
if (!f.bottleneck) return answer('Not enough pipeline movement yet to locate a bottleneck.');
const b = f.bottleneck;
const diagnosis = {
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap in the funnel to close.`,
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap to close.`,
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
}[b.to];
return compose(
heading(`Bottleneck: ${b.from} → ${b.to}`),
`Only ${b.rate}% pass through, losing ${plural(b.lost, 'candidate')}.`,
'',
diagnosis || '',
'',
bullets(f.transitions.map((t) => `${t.from} → ${t.to}: ${t.rate}%${t.lost ? ` (−${t.lost})` : ''}`))
return doc(
heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`),
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
insights([{ tone: 'warning', title: `Losing ${plural(b.lost, 'candidate')} here`, body: diagnosis }])
);
};
export const respondOverview = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next')) return pendingActions(f);
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next', 'slow')) return pendingActions(f);
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
if (has(q, 'summary', 'today', 'brief', 'catch me up', 'overview')) return hiringSummary(f);
if (has(q, 'summar', 'today', 'brief', 'catch me up', 'overview', 'morning', 'afternoon', 'evening')) return hiringSummary(f);
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
return stateOfPlay(f);
@@ -403,36 +525,44 @@ export const respondOverview = (question, f) => {
export const respondCandidates = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze')) return resumeAnalysis(f);
if (has(q, 'compare', 'versus', ' vs ', 'between')) return candidateComparison(f);
if (has(q, 'compare', 'versus', ' vs ', 'between', '&')) return candidateComparison(f);
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f);
if (has(q, 'unscreened', 'not screened', 'pending')) {
return f.unscreened.length
? compose(
? doc(
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
'',
bullets(f.unscreened.slice(0, 8).map((a) => `${a.applicant_name} — ${a.job_title}`)),
f.unscreened.length > 8 ? `…and ${f.unscreened.length - 8} more` : ''
table(
[{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }],
f.unscreened.slice(0, 8).map((a) => ({ name: a.applicant_name, role: a.job_title }))
),
f.unscreened.length > 8 && text(`…and ${f.unscreened.length - 8} more.`)
)
: 'Everyone has been screened.';
: answer('Everyone has been screened.');
}
return stateOfPlay(f);
};
export const respondAnalytics = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'forecast', 'predict', 'projection', 'will i', 'expect')) return forecastHiring(f);
if (has(q, 'report', 'export', 'executive', 'download')) return generateReports(f);
if (has(q, 'bottleneck', 'drop', 'stuck', 'lose', 'why do')) return bottleneckAnswer(f);
if (has(q, 'department', 'category', 'role', 'team', 'break down')) return departmentInsights(f);
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
if (has(q, 'forecast', 'predict', 'projection', 'will i')) return forecastHiring(f);
if (has(q, 'department', 'category', 'role', 'team')) return departmentInsights(f);
if (has(q, 'report', 'export', 'summar', 'download')) return generateReports(f);
if (has(q, 'bottleneck', 'drop', 'stuck', 'funnel', 'lose')) return bottleneckAnswer(f);
if (has(q, 'hire rate', 'conversion')) {
return `${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`;
return answer(`${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`);
}
if (has(q, 'cost', 'saved', 'roi', 'money')) {
return `$${f.costSaved.toLocaleString()} — ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each. Also about ${f.hoursSaved} recruiter hours you did not spend.`;
return doc(
kpis([
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
{ label: 'Hours returned', value: `${f.hoursSaved}h`, tone: 'success' },
]),
text(`From ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each.`)
);
}
return stateOfPlay(f);
};

View File

@@ -1,5 +1,6 @@
import { BookOpen, Compass, FileText } from 'lucide-react';
import { bullets, compose, heading, numbered, plural } from '../insights';
import { actions, answer, badges, doc, heading, insights, kpis, meters, note, status, table, text } from '../blocks';
import { plural } from '../insights';
import { getScoreBand, toFICO } from '@/lib/talentHome';
/**
@@ -16,121 +17,165 @@ const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give yo
const resumeReview = (f) => {
const p = f.profile;
if (!p) return NO_PROFILE;
if (!p) return answer(NO_PROFILE);
const missing = [];
if (!p.phone) missing.push('phone number');
if (!p.address) missing.push('location');
if (!(p.skills || []).length) missing.push('skills');
if (!(p.experience || []).length) missing.push('work history');
if (!(p.certifications || []).length) missing.push('certifications');
if (!p.career_goals) missing.push('career goals');
if (!(p.availability || []).length) missing.push('availability');
if (!p.selfie_url) missing.push('photo');
const fields = [
{ label: 'Photo', ok: Boolean(p.selfie_url), note: 'The KROW ID replaces a résumé — a profile with a face gets opened more.' },
{ label: 'Availability', ok: (p.availability || []).length > 0, note: 'Employers filter on this before they read anything else.' },
{ label: 'Certifications', ok: (p.certifications || []).length > 0, note: 'A Food Handler Card is the cheapest credential in this industry.' },
{ label: 'Skills', ok: (p.skills || []).length > 0, note: 'Without these you do not appear in skill searches.' },
{ label: 'Work history', ok: (p.experience || []).length > 0, note: 'Even one role gives employers something to anchor on.' },
{ label: 'Career goals', ok: Boolean(p.career_goals), note: 'Lets matching put you in front of the right roles, not every role.' },
{ label: 'Verified evidence', ok: (p.capabilities || []).length > 0, note: 'The only part of your profile an employer does not have to take on trust.' },
];
const present = [];
if ((p.experience || []).length) present.push(`${plural(p.experience.length, 'role')} of work history`);
if ((p.skills || []).length) present.push(`${plural(p.skills.length, 'skill')} listed`);
if ((p.certifications || []).length) present.push(p.certifications.join(', '));
if ((p.capabilities || []).length) present.push(`${plural(p.capabilities.length, 'capability')} verified by evidence`);
const complete = fields.filter((x) => x.ok).length;
return compose(
heading(`Your profile is ${p.profile_completion || 0}% complete`),
'',
present.length ? compose(heading('What is working'), bullets(present)) : '',
'',
missing.length
? compose(
heading('What is missing'),
bullets(missing),
'',
'Employers filter on availability and certifications before they read anything else. If you fix two things, fix those.'
)
: compose(heading('Nothing is missing'), 'From here, more verified evidence moves you — not more fields.'),
'',
(p.capabilities || []).length
? '_Your verified capabilities are the part employers actually trust — those came with evidence attached, not a checkbox._'
: '_Nothing is verified yet. One completed challenge is worth more than ten claimed skills._'
return doc(
heading(`Your profile is ${p.profile_completion || 0}% complete`, `${complete} of ${fields.length} things employers look for`),
status(fields.map((x) => ({ label: x.label, ok: x.ok, note: x.ok ? undefined : x.note }))),
(p.certifications || []).length && [
heading('Credentials on file'),
badges(p.certifications.map((label) => ({ label, tone: 'success' }))),
],
text(
complete === fields.length
? 'Nothing is missing. From here, more verified evidence is what moves you — not more fields.'
: 'If you fix two things, fix availability and certifications. Those are the two employers filter on first.'
),
note(
(p.capabilities || []).length
? 'Your verified capabilities came with evidence attached, not a checkbox. That is why they carry weight.'
: 'Nothing is verified yet. One completed challenge is worth more than ten claimed skills.'
)
);
};
const careerGuidance = (f) => {
const p = f.profile;
if (!p) return NO_PROFILE;
if (!p) return answer(NO_PROFILE);
const score = scoreOf(p);
const band = getScoreBand(score);
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
return compose(
heading(`You are at ${toFICO(score)} — ${band.label}`),
'',
bullets([
`Reliability ${p.reliability_score || 0}/100`,
`Attendance ${p.attendance_score ?? 0}/100`,
`${plural((p.completed_courses || []).length, 'course')} completed, ${plural((p.earned_badges || []).length, 'badge')} earned`,
`${plural((p.capabilities || []).length, 'verified capability')}`,
`${plural(p.shifts_completed || 0, 'shift')} worked`,
return doc(
heading(`${toFICO(score)} — ${band.label}`, 'Career score, on a 300–850 scale'),
kpis([
{ label: 'Reliability', value: p.reliability_score || 0, tone: (p.reliability_score || 0) >= 70 ? 'success' : 'warning' },
{ label: 'Attendance', value: p.attendance_score ?? 0, tone: (p.attendance_score ?? 0) >= 90 ? 'success' : undefined },
{ label: 'Shifts worked', value: p.shifts_completed || 0, tone: (p.shifts_completed || 0) === 0 ? 'warning' : undefined },
{ label: 'Verified skills', value: (p.capabilities || []).length },
]),
'',
target
? `**To reach ${target}:** verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.`
: '**You are in the top band.** From here it is about holding it — the score decays if shifts stop.',
'',
p.desired_position
? `You want to be ${p.desired_position}. ${score >= 75 ? 'You are already scoring in the range employers shortlist for that.' : 'The gap to that role is verification, not ambition.'}`
: 'You have not set a target role. Setting one lets me tell you which courses matter and which are noise.',
'',
(p.shifts_completed || 0) === 0
? '**One concrete step:** work a single shift. Attendance, ratings and reliability all need a first data point before anything else compounds.'
: ''
heading("What's driving it"),
meters([
{ label: 'Reliability', value: p.reliability_score || 0, weight: 25 },
{ label: 'Attendance', value: p.attendance_score ?? 0, weight: 20 },
{ label: 'Communication', value: p.performance_score || 0, weight: 15 },
{ label: 'Leadership', value: p.leadership_potential || 0, weight: 15 },
{ label: 'Learning', value: Math.min(100, (p.completed_courses || []).length * 8 + (p.xp || 0) / 10), weight: 15 },
{ label: 'Problem solving', value: p.ai_interview_score || 0, weight: 10 },
]),
insights([
target
? {
tone: 'info',
title: `To reach ${target}`,
body: 'Verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.',
}
: {
tone: 'success',
title: 'You are in the top band',
body: 'From here it is about holding it — the score decays if shifts stop.',
},
(p.shifts_completed || 0) === 0 && {
tone: 'warning',
title: 'Work one shift',
body: 'Attendance, ratings and reliability all need a first data point before anything else compounds.',
},
p.desired_position
? {
tone: score >= 75 ? 'success' : 'info',
title: `Aiming for ${p.desired_position}`,
body: score >= 75
? 'You are already scoring in the range employers shortlist for that.'
: 'The gap to that role is verification, not ambition.',
}
: {
tone: 'info',
title: 'No target role set',
body: 'Setting one lets me tell you which courses matter and which are noise.',
},
])
);
};
const learningRecommendations = (f) => {
const p = f.profile;
if (!p) return NO_PROFILE;
if (!p) return answer(NO_PROFILE);
const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
const done = (p.completed_courses || []).length;
const shifts = p.shifts_completed || 0;
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
if (!available.length) {
return compose(
return doc(
heading('You have completed everything available'),
`${plural(done, 'course')} done, ${plural((p.earned_badges || []).length, 'badge')} earned, ${p.xp || 0} XP.`,
'',
'The next lever is verified shifts — that is what employers weight most.'
kpis([
{ label: 'Courses', value: done, tone: 'success' },
{ label: 'Badges', value: (p.earned_badges || []).length, tone: 'success' },
{ label: 'XP', value: p.xp || 0 },
{ label: 'Verified', value: (p.capabilities || []).length },
]),
text('The next lever is verified shifts — that is what employers weight most.')
);
}
const byValue = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
const shifts = p.shifts_completed || 0;
const ranked = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
return compose(
heading('What to learn next'),
'',
numbered(byValue.slice(0, 3).map((c) => {
const gate = c.unlock_requirements?.min_shifts || 0;
const locked = gate > shifts;
return `**${c.title}** — ${c.difficulty}, ${c.estimated_minutes} min, +${c.xp} XP\n Proves: ${c.proof_skill || c.title}.${locked ? ` Locked until ${plural(gate, 'shift')} (you have ${shifts}).` : ' Available now.'}`;
})),
'',
return doc(
heading('What to learn next', 'Ranked by what moves your score most'),
table(
[
{ key: 'title', label: 'Course' },
{ key: 'minutes', label: 'Time', align: 'right' },
{ key: 'xp', label: 'XP', align: 'right' },
],
ranked.slice(0, 4).map((c) => {
const gate = c.unlock_requirements?.min_shifts || 0;
const locked = gate > shifts;
return {
title: c.title,
minutes: `${c.estimated_minutes}m`,
xp: locked
? { badge: `${gate} shifts`, tone: 'warning' }
: { value: `+${c.xp}`, tone: 'success' },
};
})
),
heading('Where you stand'),
bullets([
`${plural(done, 'course')} completed`,
`${plural((p.earned_badges || []).length, 'badge')} earned`,
`${p.xp || 0} XP`,
`${plural((p.capabilities || []).length, 'capability')} verified`,
kpis([
{ label: 'Courses done', value: done },
{ label: 'Badges', value: (p.earned_badges || []).length },
{ label: 'XP', value: p.xp || 0 },
{ label: 'Verified skills', value: (p.capabilities || []).length },
]),
'',
done === 0
? 'Start with the shortest one. The point of the first course is not the XP — it is getting one verified capability on the board.'
: done < 3
? `Three completions is where the score starts moving noticeably, because education carries real weight in the formula. You are at ${done}.`
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
'',
'Each of these ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored. That is why the badge means something to an employer.'
actions([
{
title: done === 0 ? `Start with ${ranked[ranked.length - 1].title}` : `Take ${ranked[0].title}`,
body: done === 0
? 'The point of the first course is not the XP — it is getting one verified capability on the board.'
: done < 3
? `Three completions is where the score starts moving noticeably. You are at ${done}.`
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
},
shifts < 5 && {
title: `Log ${plural(5 - shifts, 'more shift')}`,
body: `Advanced challenges stay locked until 5 shifts — you have ${shifts}. That gate is deliberate: leadership evidence from someone who has never run a floor is not evidence.`,
},
]),
note('Each course ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored.')
);
};
@@ -140,14 +185,16 @@ const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondTalent = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'resume', 'résumé', 'cv', 'profile')) return resumeReview(f);
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge')) return learningRecommendations(f);
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next')) return careerGuidance(f);
if (has(q, 'resume', 'résumé', 'cv', 'profile', 'improve')) return resumeReview(f);
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge', 'path')) return learningRecommendations(f);
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next', 'started')) return careerGuidance(f);
const p = f.profile;
return p
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
: NO_PROFILE;
return answer(
p
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
: NO_PROFILE
);
};
export const TALENT_CAPABILITIES = [

View File

@@ -11,18 +11,10 @@ import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
/**
* Page contexts for the Krow AI Assistant.
*
* The component is identical on every page. Only what is in this file changes:
* the supporting line, the placeholder examples, the suggested chips, the
* capabilities and the responder.
*
* Chips are phrased as things a person would actually type, because clicking one
* fills the input rather than firing an action. The user always sees the
* question before it is asked, so the assistant reads as a conversation rather
* than a menu of buttons.
*
* `capability` binds a chip to a specific capability, so a chip gets a precise
* answer while an equivalent typed question still resolves through the
* responder's keyword matching.
* Deliberately thin. What the user sees on the landing screen — the title,
* greeting, description, placeholders and suggested prompts — is computed from
* live data in `dynamic.js`, not written here. A context only declares what this
* page's assistant can *do*: its capabilities and its free-text responder.
*
* `needsWorkerProfile` gates the one query with a side effect, so employer and
* admin sessions never touch worker-profile records.
@@ -31,129 +23,42 @@ export const ASSISTANT_CONTEXTS = {
'employer.overview': {
id: 'employer.overview',
page: 'Overview',
tagline: 'Find insights from your hiring data, spot what needs attention, and ask questions about this page.',
placeholders: [
"Ask about today's hiring…",
'What should I do first?',
'Which roles are at risk?',
'How healthy is my pipeline?',
],
chips: [
{ label: "Today's Hiring", prompt: 'Give me a summary of hiring today', capability: 'hiring-summary' },
{ label: 'Pipeline Health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
{ label: 'Pending Actions', prompt: 'What is pending and needs my attention?', capability: 'pending-actions' },
{ label: 'Hiring Risks', prompt: 'What risks should I know about?', capability: 'hiring-risks' },
],
capabilities: OVERVIEW_CAPABILITIES,
respond: respondOverview,
},
'employer.candidates': {
id: 'employer.candidates',
page: 'Candidates',
tagline: 'Analyse résumés, compare candidates, generate interview questions, and decide who to move forward.',
placeholders: [
'Compare two candidates…',
'Who should I interview next?',
'What are this candidate’s gaps?',
'Draft interview questions…',
],
chips: [
{ label: 'Top Candidates', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
{ label: 'Compare Candidates', prompt: 'Compare my top two candidates', capability: 'candidate-comparison' },
{ label: 'Interview Questions', prompt: 'Generate interview questions for my top candidate', capability: 'interview-questions' },
{ label: 'Skill Gap Analysis', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
{ label: 'Hiring Recommendation', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
],
capabilities: CANDIDATE_CAPABILITIES,
respond: respondCandidates,
},
'employer.analytics': {
id: 'employer.analytics',
page: 'Analytics',
tagline: 'Explain the numbers on this page, forecast what your pipeline will yield, and generate reports.',
placeholders: [
'Explain this chart…',
'Generate a hiring report…',
'Where do candidates drop off?',
'How many hires can I expect?',
],
chips: [
{ label: 'Explain Charts', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
{ label: 'Forecast Hiring', prompt: 'Forecast my hiring from the current pipeline', capability: 'forecast-hiring' },
{ label: 'Department Insights', prompt: 'Break down performance by role category', capability: 'department-insights' },
{ label: 'Generate Report', prompt: 'Generate a hiring report', capability: 'generate-reports' },
],
capabilities: ANALYTICS_CAPABILITIES,
respond: respondAnalytics,
},
'admin.controlCenter': {
id: 'admin.controlCenter',
page: 'Control Center',
tagline: 'Check platform health, reconcile the data, and see what needs intervention across every account.',
placeholders: [
'Is anything broken?',
'What should I fix first?',
'How is the platform performing?',
],
chips: [
{ label: 'Platform Health', prompt: 'Run a platform health check', capability: 'platform-health' },
{ label: 'Recommendations', prompt: 'What should I fix first?', capability: 'recommendations' },
],
capabilities: CONTROL_CENTER_CAPABILITIES,
respond: respondControlCenter,
},
'admin.candidates': {
id: 'admin.candidates',
page: 'Candidates Analysis',
tagline: 'Analyse talent supply across the platform and see where the risk sits in the candidate pool.',
placeholders: [
'How healthy is talent supply?',
'Any candidate risk flags?',
'What are the recruitment trends?',
],
chips: [
{ label: 'Recruitment Insights', prompt: 'Show me recruitment insights across the platform', capability: 'recruitment-insights' },
{ label: 'Candidate Risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
],
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
respond: respondAdminCandidates,
},
'admin.activity': {
id: 'admin.activity',
page: 'Activity',
tagline: 'Summarize the audit trail, reconcile it against real records, and see how accounts are behaving.',
placeholders: [
'Summarize the audit trail…',
'Who is most active?',
'Are there any gaps in the log?',
],
chips: [
{ label: 'Audit Summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
{ label: 'User Activity', prompt: 'Which accounts are most active?', capability: 'user-activity' },
],
capabilities: ACTIVITY_CAPABILITIES,
respond: respondActivity,
},
'talent.portal': {
id: 'talent.portal',
page: 'My Portal',
tagline: 'Understand your score, improve your profile, and find the next thing worth learning.',
placeholders: [
'How do I earn more?',
'Review my profile…',
'What should I learn next?',
],
chips: [
{ label: 'Review My Profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
{ label: 'Career Guidance', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
{ label: 'What To Learn Next', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
],
capabilities: TALENT_CAPABILITIES,
respond: respondTalent,
needsWorkerProfile: true,

View File

@@ -0,0 +1,265 @@
import { plural, verb } from './insights';
/**
* Dynamic greetings and suggested prompts.
*
* Nothing here is a fixed string chosen in advance. The greeting states what is
* actually true on the page right now, and the suggestions name real records —
* the specific candidates, the specific starved role. That is the difference
* between an assistant that looks aware of context and one that is.
*
* Everything derives from the same fact sheet the dashboard renders from, so a
* greeting can never contradict the numbers beside it.
*/
/* ── Greetings ──────────────────────────────────────────────────────────── */
/**
* Time of day from the viewer's own clock.
*
* `getHours()` is local, so this follows the user's timezone rather than the
* server's. The late band matters: at 00:30 "morning" is wrong, so 21:00–04:59
* reads as evening.
*
* 05:00–11:59 morning
* 12:00–16:59 afternoon
* 17:00–20:59 evening
* 21:00–04:59 evening
*/
function timeOfDay(date) {
const hour = date.getHours();
if (hour >= 5 && hour < 12) return 'morning';
if (hour >= 12 && hour < 17) return 'afternoon';
return 'evening';
}
/**
* Picks the single most useful thing to say about the page.
*
* Ordered by urgency rather than by what sounds cheeriest: something stuck beats
* something healthy, because the greeting is the one line guaranteed to be read.
*/
const GREETINGS = {
'employer.overview': (f) => {
if (!f.total) return 'No applicants yet — publish a position and I will start tracking it.';
if (f.stalled.length) {
return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`;
}
if (f.unscreened.length) {
return `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
}
if (f.starvedPositions.length) {
return `${plural(f.starvedPositions.length, 'open role')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants yet.`;
}
return 'Your hiring pipeline looks healthy today.';
},
'employer.candidates': (f) => {
if (!f.total) return 'No candidates in the pipeline yet.';
if (f.unscreened.length) {
return `You have ${plural(f.unscreened.length, 'candidate')} waiting for review.`;
}
if (f.stalled.length) {
return `${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'is', 'are')} ready for a decision.`;
}
return `All ${f.total} candidates are scored and actioned.`;
},
'employer.analytics': (f) => {
if (!f.scored.length) return 'Nothing scored yet, so the charts are still empty.';
if (f.bottleneck && f.bottleneck.lost > 0) {
return `Your funnel loses most people between ${f.bottleneck.from} and ${f.bottleneck.to}.`;
}
return `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. Worth a closer look.`;
},
'admin.controlCenter': (f) => {
if (f.unscoredProfiles.length) {
return `${plural(f.unscoredProfiles.length, 'talent profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score and cannot be matched.`;
}
if (f.activity24h.length) {
return `${plural(f.activity24h.length, 'event')} logged in the last 24 hours.`;
}
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
},
'admin.candidates': (f) => {
if (f.missingCredentials.length) {
return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
}
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} at Elite.`;
},
'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.`;
}
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`;
},
'talent.portal': (f) => {
const p = f.profile;
if (!p) return 'Let us get your profile started.';
const completion = p.profile_completion || 0;
if (completion >= 90) return 'Your profile is nearly complete — nice work.';
if (completion > 0) return `You are ${completion}% of the way through your profile.`;
return 'Your profile is empty. A few fields will change what employers see.';
},
};
/** Title lines. First names read better than a formal greeting in a small panel. */
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',
'talent.portal': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
const DESCRIPTIONS = {
'employer.overview': (f) =>
`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.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
'talent.portal': () => 'Your score, your profile, and what to learn next.',
};
/** Placeholders phrased as things this page's user would actually type. */
const PLACEHOLDERS = {
'employer.overview': (f) => [
`Summarize ${timeOfDay(f.today)}'s hiring…`,
f.starvedPositions.length ? 'Which positions need attention?' : 'What should I do first?',
'Why did hiring slow this week?',
],
'employer.candidates': (f) => [
f.ranked.length >= 2 ? 'Compare the top candidates…' : 'Analyse my strongest candidate…',
'Which resumes need review?',
'Recommend interview questions…',
],
'employer.analytics': () => [
'Explain this chart…',
'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…'],
'talent.portal': () => ['Improve my profile…', 'Recommend learning paths…'],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
/**
* Builds suggestions from what is actually on the page.
*
* A suggestion that names two real candidates is a different product from one
* that says "Compare candidates" — it proves the assistant already looked.
* Each carries the capability that answers it best, so a click is precise.
*/
const PROMPTS = {
'employer.overview': (f) => [
{ label: `Summarize ${timeOfDay(f.today)}'s hiring`, prompt: `Summarize hiring as of this ${timeOfDay(f.today)}`, capability: 'hiring-summary' },
f.starvedPositions.length
? { label: 'Which positions need attention?', prompt: `Which positions need attention? ${f.starvedPositions[0].title} has no applicants`, capability: 'pending-actions' }
: { label: 'What needs my attention?', prompt: 'What needs my attention right now?', capability: 'pending-actions' },
{ label: 'Pipeline health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
f.singleCandidateRoles.length || f.missingCredentials.length
? { label: 'Where is the risk?', prompt: 'Where is the risk in my hiring right now?', capability: 'hiring-risks' }
: { label: 'Any hiring risks?', prompt: 'Are there any hiring risks I should know about?', capability: 'hiring-risks' },
],
'employer.candidates': (f) => {
const [a, b] = f.ranked;
return [
a && b
? { label: `Compare ${a.applicant_name.split(' ')[0]} & ${b.applicant_name.split(' ')[0]}`, prompt: `Compare ${a.applicant_name} and ${b.applicant_name}`, capability: 'candidate-comparison' }
: { label: 'Compare top candidates', prompt: 'Compare my top candidates', capability: 'candidate-comparison' },
f.unscreened.length
? { label: `${f.unscreened.length} resumes need review`, prompt: 'Which resumes need review?', capability: 'resume-analysis' }
: { label: 'Analyse top résumé', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
a
? { label: `Questions for ${a.applicant_name.split(' ')[0]}`, prompt: `Recommend interview questions for ${a.applicant_name}`, capability: 'interview-questions' }
: null,
{ label: 'Skill gaps', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
{ label: 'Who should I hire?', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
].filter(Boolean);
},
'employer.analytics': (f) => [
{ label: 'Explain this chart', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
{ label: 'Forecast hiring', prompt: 'Forecast hiring from the current pipeline', capability: 'forecast-hiring' },
{ label: 'Generate executive summary', prompt: 'Generate an executive hiring report', capability: 'generate-reports' },
f.bottleneck
? {
label: `Why the drop to ${f.bottleneck.to}?`,
prompt: `Why do candidates drop off between ${f.bottleneck.from} and ${f.bottleneck.to}?`,
capability: 'explain-charts',
}
: null,
{ label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' },
].filter(Boolean),
'admin.controlCenter': (f) => [
{ 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' },
],
'admin.candidates': (f) => [
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
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' },
],
'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),
'talent.portal': (f) => {
const p = f.profile;
return [
{ label: 'Improve my profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
{ label: 'Recommend learning paths', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
p && (p.krow_score || 0) > 0
? { label: 'How do I score higher?', prompt: 'How do I raise my career score?', capability: 'career-guidance' }
: { label: 'How do I get started?', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
];
},
};
/* ── Public API ─────────────────────────────────────────────────────────── */
const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] || '';
/**
* Everything the landing screen needs, computed from current data.
* `userName` personalizes the greeting where a name is known.
*/
export function buildIntro(contextId, facts, userName) {
const name = firstName(userName);
return {
title: TITLES[contextId]?.(facts, name) ?? 'Krow AI',
greeting: GREETINGS[contextId]?.(facts) ?? '',
description: DESCRIPTIONS[contextId]?.(facts) ?? '',
placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'],
};
}
/** Suggested prompts for the current page and data. */
export function buildPrompts(contextId, facts) {
return PROMPTS[contextId]?.(facts) ?? [];
}

View File

@@ -2,33 +2,32 @@
* Provider seam for the Krow AI Assistant.
*
* The UI never generates an answer and never knows where one came from. It calls
* `provider.stream(request)` and renders the chunks. That single boundary is
* `provider.stream(request)` and renders the snapshots. That single boundary is
* what makes the backend swappable: today a local provider computes answers from
* dashboard data, and later an HTTP provider can call a real service without a
* single change to the component.
* change to the component.
*
* ── Contract ────────────────────────────────────────────────────────────────
*
* provider.id: string
* provider.stream(request): AsyncIterable<string> // yields text deltas
* provider.stream(request): AsyncIterable<Block[]>
*
* Each yielded value is the response *so far* as an array of blocks (see
* blocks.js). Snapshots rather than deltas because a response is structured:
* a table or a KPI row has no meaningful half-state, and the renderer stays a
* pure function of the latest snapshot.
*
* request = {
* contextId: string, // e.g. 'employer.overview'
* capability: string | null, // capability id, or null for free text
* question: string, // what the user typed, or the capability label
* facts: object, // the dashboard fact sheet (see insights.js)
* question: string, // what the user typed, or the chip's prompt
* facts: object, // dashboard fact sheet (see insights.js)
* signal: AbortSignal, // aborts an in-flight response
* }
*
* Deltas rather than a whole string is deliberate: it is the shape every
* streaming completion API returns, so the local provider and a future remote
* one are interchangeable without the UI learning a second pattern.
*/
import { getContext } from './contexts';
/** Words per delta. Chunking by word keeps reflow to once per word. */
const WORDS_PER_CHUNK = 3;
import { toSnapshots } from './blocks';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -38,7 +37,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
* Deterministic: the same question against the same data returns the same
* answer, which is what makes the assistant demonstrable and testable.
*/
export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
return {
id: 'local',
@@ -46,21 +45,20 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
const context = getContext(contextId);
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
const answer = capability
const document = capability
? context.capabilities.find((c) => c.id === capability)?.run(facts)
?? context.respond(question, facts)
: context.respond(question, facts);
// A brief pause before the first delta, so the answer reads as considered
// A brief pause before the first frame, so the answer reads as considered
// rather than precomputed.
await sleep(latency);
if (signal?.aborted) return;
const tokens = String(answer).split(/(\s+)/);
for (let i = 0; i < tokens.length; i += WORDS_PER_CHUNK * 2) {
for (const snapshot of toSnapshots(document)) {
if (signal?.aborted) return;
yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join('');
await sleep(chunkDelay);
yield snapshot;
await sleep(frameDelay);
}
},
};
@@ -69,15 +67,15 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
/**
* HTTP provider — the path to a real backend.
*
* Reads Server-Sent-Event style `data:` lines from a streaming endpoint and
* yields the text deltas. Unused today; it exists so the shape of the
* integration is settled rather than guessed at later. Swapping providers is a
* one-line change in `AssistantProvider`.
* Reads Server-Sent-Event style `data:` lines and yields a growing block array.
* A line may carry a whole block (`{ block: … }`) or a text delta
* (`{ delta: "…" }`), which is appended to a trailing text block. Unused today;
* it exists so the shape of the integration is settled rather than guessed at.
*
* The request body sends `contextId`, `capability` and `question` — not the
* fact sheet. Dashboard data should be fetched server-side from the caller's own
* session rather than posted from the browser, so the client cannot ask about
* records it is not entitled to see.
* The request body sends `contextId`, `capability` and `question` — not the fact
* sheet. Dashboard data should be read server-side from the caller's own session
* rather than posted from the browser, so the client cannot ask about records it
* is not entitled to see.
*/
export function createHttpProvider({ endpoint, headers = {} }) {
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
@@ -99,28 +97,37 @@ export function createHttpProvider({ endpoint, headers = {} }) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
const blocks = [];
let buffer = '';
const appendDelta = (delta) => {
const last = blocks[blocks.length - 1];
if (last?.type === 'text') last.text += delta;
else blocks.push({ type: 'text', text: delta });
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// The final element may be a partial line; keep it for the next read.
// The final element may be a partial line; hold it for the next read.
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
try {
const parsed = JSON.parse(payload);
if (parsed.delta) yield parsed.delta;
if (parsed.block) blocks.push(parsed.block);
else if (parsed.delta) appendDelta(parsed.delta);
} catch {
// A non-JSON data line is treated as a raw delta.
yield payload;
appendDelta(payload);
}
yield [...blocks];
}
}
},
@@ -128,10 +135,9 @@ export function createHttpProvider({ endpoint, headers = {} }) {
}
/**
* The provider the app uses.
*
* Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to
* switch, with no other code change.
* The provider the app uses. Local by default; point
* `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
* code change.
*/
export function createAssistantProvider() {
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;

View File

@@ -48,7 +48,7 @@ export function useConversation({ contextId, facts }) {
const storageKey = `krow_assistant:${contextId}`;
const [messages, setMessages] = React.useState([]);
const [pending, setPending] = React.useState(null); // { question, text, thinking }
const [pending, setPending] = React.useState(null); // { blocks, thinking }
const [error, setError] = React.useState(null);
const abortRef = React.useRef(null);
@@ -93,28 +93,26 @@ export function useConversation({ contextId, facts }) {
messagesRef.current = withUser;
persist(withUser);
setError(null);
setPending({ question: text, text: '', thinking: true });
setPending({ blocks: [], thinking: true });
try {
let assembled = '';
for await (const delta of provider.stream({
let latest = [];
for await (const snapshot of provider.stream({
contextId, capability, question: text, facts, signal: controller.signal,
})) {
if (controller.signal.aborted) break;
assembled += delta;
setPending({ question: text, text: assembled, thinking: false });
latest = snapshot;
setPending({ blocks: snapshot, thinking: false });
}
if (controller.signal.aborted) {
// Keep whatever streamed before the user stopped it — discarding a
// half-written answer loses information the user was already reading.
if (assembled) {
const next = [...messagesRef.current, { role: 'assistant', text: assembled, stopped: true }];
messagesRef.current = next;
persist(next);
}
} else {
const next = [...messagesRef.current, { role: 'assistant', text: assembled }];
// Keep whatever arrived before the user stopped it — discarding a
// half-written answer loses what they were already reading. Stopping
// before the first block, though, should leave no empty turn behind.
if (latest.length) {
const next = [
...messagesRef.current,
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
];
messagesRef.current = next;
persist(next);
}