ui changes

This commit is contained in:
2026-08-10 19:36:45 +05:30
parent 409dffe938
commit 5cd49a1655
49 changed files with 7988 additions and 470 deletions

View File

@@ -24,7 +24,7 @@ export function ThinkingIndicator() {
/>
))}
</span>
<span className="sr-only">Krow AI is preparing a response</span>
<span className="sr-only">Owliver is preparing a response</span>
</div>
);
}
@@ -54,7 +54,7 @@ export const Message = React.memo(({ role, text, blocks, streaming, stopped }) =
<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>
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} />

View File

@@ -1,27 +1,34 @@
import * as React from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { PanelLeftOpen, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import KrowAssistant from './KrowAssistant';
import { useAssistantPanel } from './AssistantPanelContext';
import { ResizeDivider } from './ResizeDivider';
const DEFAULT_WIDTH = 380;
const EXPANDED_WIDTH = 620;
/** Gutter separating the panel from the dashboard. */
const GUTTER = 24;
/**
* Gutter separating the panel from the dashboard, split between the drag handle
* and the panel's own lead padding. Both halves are named because the track width
* is their sum plus the panel: get that arithmetic wrong and the column either
* overflows or leaves a strip of unexplained empty space at the shell's edge.
*/
const HANDLE_WIDTH = 12;
const LEAD_PADDING = 8;
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
/** Expanded must never dominate: the dashboard stays the primary experience. */
const MAX_VIEWPORT_SHARE = 0.42;
/**
* Below this width there is no room for a column *beside* the dashboard — a
* 380px track on a 375px phone collapses `main` to nothing — so the panel
* becomes a bottom sheet over the page instead of an inline column.
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stacks
* underneath the page instead.
*
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the
* inline column out acceptably, so they keep the existing behaviour and only
* phones switch to the sheet.
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
* column out acceptably, so they keep the two-column workspace and only phones
* stack.
*/
const OVERLAY_BREAKPOINT = 768;
const STACK_BREAKPOINT = 768;
/** Tracks viewport width so the expanded panel can be clamped on smaller screens. */
/** Tracks viewport width so the panel can be clamped and the layout switched. */
function useViewportWidth() {
const [width, setWidth] = React.useState(() =>
typeof window === 'undefined' ? 1440 : window.innerWidth
@@ -37,121 +44,156 @@ function useViewportWidth() {
}
/**
* AssistantPanel — the sliding column the layout renders.
* The collapsed state — a compact docked trigger.
*
* Animating the aside's `width` (rather than overlaying) is what makes the
* dashboard reflow smoothly instead of being covered, and what lets closing
* restore the original layout exactly.
* The earlier version was a full-height rail inside the layout column. It solved
* the wrong problem: it kept Owliver *findable*, but it also kept the column, so
* collapsing bought the dashboard 44px instead of the 400px the user was asking
* for, and it did it with rotated text down the side of the page.
*
* Two structural details matter, and both were bugs at one point:
* So collapsing now removes the column entirely — the layout goes to one column
* and `main` takes the whole shell — and the way back is this small docked pill.
* It is deliberately the only floating control in the product, and it exists only
* in the collapsed state, where there is nowhere else for it to live.
*/
function CollapsedTrigger({ page, onRestore }) {
return (
<button
type="button"
onClick={onRestore}
aria-label={`Show the Owliver workspace for ${page}`}
aria-expanded={false}
className="group fixed bottom-5 right-5 z-30 inline-flex items-center gap-2 rounded-full border border-border
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
hover:border-krow-blue/40 hover:shadow-lg
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-krow-blue text-white shadow-brand">
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
</button>
);
}
/**
* AssistantPanel — the Owliver workspace column the Admin layout renders.
*
* 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.
* Owliver is part of the page on every supported route, so this is a column in
* the layout rather than an overlay: the dashboard reflows beside it instead of
* being covered, and collapsing restores the original layout exactly.
*
* Five structural details matter, and every one of them was a bug at some point:
*
* 1. Nothing above the sticky element clips. 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.
* 3. `min-w-0` on the aside. A flex item's default `min-width: auto` refuses to
* go narrower than its content, so the track ignored its own width and stalled
* at the panel's old size on the way back from expanded.
* 4. Only the track has a width. The panel fills what the track leaves after the
* handle and the lead padding, so the two can never disagree — which is
* exactly what (3) was: two widths transitioning, one winning.
* 5. Collapsing removes the column outright and leaves a compact docked trigger.
* An earlier version kept a full-height rail in place, which bought the
* dashboard 44px when the user had asked for 400 — and spelled "Owliver" down
* the side of the page to do it.
*
* 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.
* Expanding widens the same panel in place — an analysis workspace inside the
* application layout, never a fullscreen takeover.
*/
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
const { context, isOpen, isExpanded, close, expand, restore } = useAssistantPanel();
const {
context, isOpen, isExpanded, width, minWidth, maxWidth,
setWidth, resetWidth, open, close, expand, restore,
} = useAssistantPanel();
const viewportWidth = useViewportWidth();
// Clamped so a wide panel cannot squeeze the dashboard on a small screen.
// No assistant on this route: no column, no rail, no trace in the layout.
if (!context) return null;
/* Expanded overrides the dragged width; otherwise the user's own width wins.
Both are clamped against the viewport so the dashboard is never squeezed. */
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
const panelWidth = isExpanded
? Math.max(DEFAULT_WIDTH, Math.min(EXPANDED_WIDTH, Math.round(viewportWidth * MAX_VIEWPORT_SHARE)))
: DEFAULT_WIDTH;
? Math.max(width, Math.min(EXPANDED_WIDTH, viewportCap))
: Math.min(width, Math.max(minWidth, viewportCap));
/* Collapsed: no column at all. The layout falls back to a single column, `main`
takes the whole shell, and the only thing left is the docked trigger — so
there is never a reserved empty gutter where the panel used to be. */
if (!isOpen) {
return <CollapsedTrigger page={context.page} onRestore={open} />;
}
const trackWidth = panelWidth + GUTTER;
const easing = [0.65, 0, 0.35, 1];
// Phone/small-tablet: a sheet over the dashboard rather than a column beside
// it. Same assistant, same props — only the container differs.
if (viewportWidth < OVERLAY_BREAKPOINT) {
/* Phones: stack Owliver under the dashboard. A 380px column beside the page is
not a layout at this width, and an overlay that opens on load would put a
sheet between the user and the page they asked for. */
if (viewportWidth < STACK_BREAKPOINT) {
return (
<AnimatePresence initial={false}>
{isOpen && context && (
<>
<motion.div
className="fixed inset-0 z-40 bg-black/40"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: easing }}
onClick={close}
/>
<motion.aside
key="assistant-sheet"
aria-label="Krow AI Assistant"
className="fixed inset-x-0 bottom-0 z-50 max-h-[85dvh] overflow-y-auto overscroll-contain rounded-t-2xl bg-white shadow-2xl"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ duration: 0.3, ease: easing }}
>
<KrowAssistant
key={context.id}
context={context}
expanded={false}
onClose={close}
onExpand={expand}
onRestore={restore}
/>
</motion.aside>
</>
)}
</AnimatePresence>
<aside aria-label="Owliver workspace" className="pb-6 pt-2">
<KrowAssistant
key={context.id}
context={context}
expanded={false}
onClose={close}
onExpand={null}
onRestore={null}
className="h-[32rem]"
/>
</aside>
);
}
return (
<AnimatePresence initial={false}>
{isOpen && context && (
<motion.aside
key="assistant"
aria-label="Krow AI Assistant"
initial={{ width: 0, opacity: 0 }}
animate={{ width: trackWidth, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
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 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: 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>
</motion.div>
</div>
</motion.aside>
)}
</AnimatePresence>
<aside
aria-label="Owliver workspace"
style={{ width: trackWidth }}
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
`min-w-0` — see (3).
A CSS transition rather than an animated width: the width is already a
pure function of state, so there is nothing for an animation library to
own, and the declarative version cannot get stranded mid-collapse the way
an interrupted JS animation can. Reduced motion is handled globally in
index.css. */
className="relative min-w-0 shrink-0 self-stretch transition-[width] duration-slow ease-out"
>
<div className={cn('sticky flex', stickyClassName)}>
{/* Drag handle. Sits in the gutter, so resizing never overlaps either
surface. Double-click restores the default width. */}
<ResizeDivider
width={panelWidth}
min={minWidth}
max={Math.min(maxWidth, viewportCap)}
onResize={setWidth}
onDoubleClick={resetWidth}
className={panelHeightClassName}
/>
{/* The panel takes whatever the track leaves after the handle and the
lead padding, which is `panelWidth` by construction — see (4). */}
<div className="min-w-0 flex-1 pl-2">
{/* 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}
/>
</div>
</div>
</aside>
);
}

View File

@@ -2,31 +2,48 @@ import * as React from 'react';
import { resolveAssistantContext } from './placement';
/**
* Window state for the Krow AI Assistant: open/closed and default/expanded.
* Window state for the Owliver panel: open/collapsed 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
* where, and without threading props through every page.
* Lives in context rather than in the layout so any control — the panel's own
* header, the collapsed rail — can change the workspace without the layout
* having to know where it sits, and without threading props through every page.
*
* The assistant is closed by default. The dashboard is the primary experience;
* the assistant is something you open when you want it.
* **Owliver is open by default.** On a supported Admin page the workspace *is*
* the dashboard plus Owliver: it is an intelligence layer over the page, not a
* feature you go and fetch. Collapsing is therefore a deliberate act by someone
* who wants the full width for a while, which is why the collapsed state is a
* visible rail rather than a dismissal (see AssistantPanel).
*/
const AssistantPanelContext = React.createContext(null);
const OPEN_KEY = 'krow_assistant_open';
const EXPANDED_KEY = 'krow_assistant_expanded';
const WIDTH_KEY = 'krow_assistant_width';
/** Drag limits. Below 340px the reports stop being readable; above 520px the
* panel starts competing with the dashboard for primacy. */
export const MIN_WIDTH = 340;
export const MAX_WIDTH = 520;
export const DEFAULT_WIDTH = 380;
const clampWidth = (value) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(value)));
/**
* 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.
* not undo a panel the user deliberately collapsed or expanded, while a new
* session still starts from the default workspace.
*
* `fallback` is what an untouched session gets. Absence has to mean "default"
* rather than "off", or the open-by-default panel would only appear after
* someone had already opened it once.
*/
function readFlag(key) {
function readFlag(key, fallback = false) {
try {
return sessionStorage.getItem(key) === '1';
const stored = sessionStorage.getItem(key);
return stored === null ? fallback : stored === '1';
} catch {
return false;
return fallback;
}
}
@@ -39,8 +56,16 @@ function writeFlag(key, value) {
}
export function AssistantPanelProvider({ role, pathname, children }) {
const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY));
const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY, true));
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
const [width, setWidth] = React.useState(() => {
try {
const stored = Number(sessionStorage.getItem(WIDTH_KEY));
return stored ? clampWidth(stored) : DEFAULT_WIDTH;
} catch {
return DEFAULT_WIDTH;
}
});
const context = React.useMemo(
() => resolveAssistantContext(role, pathname),
@@ -63,14 +88,19 @@ export function AssistantPanelProvider({ role, pathname, children }) {
}, []);
/**
* 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.
* Escape steps back one level rather than collapsing outright: expanded →
* default → collapsed. Narrowing a workspace and putting it away are different
* intentions, and one key press should not do both.
*
* Ignored while the caret is in the composer, where Escape belongs to whatever
* the user is typing rather than to the layout.
*/
React.useEffect(() => {
if (!isOpen) return undefined;
const onKey = (e) => {
if (e.key !== 'Escape') return;
const tag = document.activeElement?.tagName;
if (tag === 'TEXTAREA' || tag === 'INPUT') return;
if (isExpanded) setExpandedState(false);
else setOpenState(false);
};
@@ -78,17 +108,37 @@ export function AssistantPanelProvider({ role, pathname, children }) {
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, isExpanded, setExpandedState, setOpenState]);
/* Width is written on every drag frame, so persistence is debounced rather
than hitting sessionStorage sixty times a second. */
const setWidthState = React.useCallback((next) => {
const clamped = clampWidth(next);
setWidth(clamped);
clearTimeout(setWidthState.timer);
setWidthState.timer = setTimeout(() => {
try {
sessionStorage.setItem(WIDTH_KEY, String(clamped));
} catch {
// Non-persistent session is fine.
}
}, 200);
}, []);
const value = React.useMemo(() => ({
context,
supported,
isOpen,
isExpanded,
width,
minWidth: MIN_WIDTH,
maxWidth: MAX_WIDTH,
setWidth: setWidthState,
resetWidth: () => setWidthState(DEFAULT_WIDTH),
open: () => setOpenState(true),
close: () => setOpenState(false),
toggle: () => setOpenState(!isOpen),
expand: () => setExpandedState(true),
restore: () => setExpandedState(false),
}), [context, supported, isOpen, isExpanded, setOpenState, setExpandedState]);
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState]);
return (
<AssistantPanelContext.Provider value={value}>
@@ -109,6 +159,11 @@ export function useAssistantPanel() {
supported: false,
isOpen: false,
isExpanded: false,
width: DEFAULT_WIDTH,
minWidth: MIN_WIDTH,
maxWidth: MAX_WIDTH,
setWidth: () => {},
resetWidth: () => {},
open: () => {},
close: () => {},
toggle: () => {},

View File

@@ -20,7 +20,7 @@ export function AssistantTrigger({ className, size = 'default' }) {
if (!supported) return null;
const label = isOpen ? 'Close Krow AI' : `Ask Krow AI about ${context.page}`;
const label = isOpen ? 'Close Owliver' : `Ask Owliver about ${context.page}`;
const heights = { sm: 'h-8 px-2.5', default: 'h-9 px-3' };
const logoHeights = { sm: 'h-3.5', default: 'h-4' };

View File

@@ -1,10 +1,9 @@
import * as React from 'react';
import { Maximize2, Minimize2, RotateCcw, Sparkles, X } from 'lucide-react';
import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } 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, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
@@ -12,7 +11,7 @@ import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
/**
* Krow AI Assistant — the dashboard's contextual assistant.
* Owliver — the dashboard's contextual panel.
*
* One component, reused on every supported page; only `context` differs.
*
@@ -20,13 +19,11 @@ import { PromptChips } from './PromptChips';
* 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.
*
* Two states share that frame:
*
* - **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.
* The frame does not change between states, only the body's content does: an
* empty thread shows the greeting and this page's most relevant fact, and once a
* question is asked the same region holds the conversation. Keeping the composer
* anchored either way means the primary action never moves under the user, and
* the panel never reads as two different components.
*
* `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
@@ -104,7 +101,7 @@ export default function KrowAssistant({
data-wide={expanded || undefined}
className={cn('flex flex-col overflow-hidden', className)}
role="region"
aria-label="Krow AI Assistant"
aria-label="Owliver assistant"
>
{/* Header */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
@@ -113,7 +110,7 @@ export default function KrowAssistant({
</span>
<div className="min-w-0 flex-1">
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Krow AI</h2>
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
</div>
@@ -124,56 +121,42 @@ export default function KrowAssistant({
)}
{expanded
? onRestore && (
<IconButton icon={Minimize2} label="Restore panel width" variant="ghost" size="sm" onClick={onRestore} />
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
)
: onExpand && (
<IconButton icon={Maximize2} label="Expand to workspace" variant="ghost" size="sm" onClick={onExpand} />
<IconButton icon={Maximize2} label="Expand the Owliver workspace" variant="ghost" size="sm" onClick={onExpand} />
)}
{onClose && (
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
<IconButton icon={PanelRightClose} label="Collapse the Owliver workspace" variant="ghost" size="sm" onClick={onClose} />
)}
</div>
</div>
{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>
<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} />
{/* ── Body: the only region that scrolls ─────────────────────────── */}
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5"
aria-live="polite"
aria-relevant="additions text"
>
{isEmpty ? (
/* Greeting and the single most relevant fact about this page. Top
aligned rather than centred: it is the first thing in a
conversation, not a splash screen, so it belongs where the first
message would be. */
<div className="animate-fade-in">
<p className="font-heading text-body-lg font-semibold leading-snug text-ink-1">
{intro.title}
</p>
<p className="mt-1 text-balance text-body-sm leading-relaxed text-ink-2">
{intro.greeting}
</p>
<p className="mt-2.5 border-t border-border/70 pt-2.5 text-caption leading-relaxed text-ink-3">
{intro.description}
</p>
</div>
</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"
>
) : (
<div className="space-y-4">
{messages.map((message, i) => (
<React.Fragment key={i}>
{i > 0 && message.role === 'user' && <TurnDivider />}
@@ -193,25 +176,28 @@ export default function KrowAssistant({
{error && <Alert tone="destructive">{error}</Alert>}
</div>
)}
</div>
<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"
/>
)}
{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>
</>
)}
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
{/* All suggestions on the landing screen, where they teach what can be
asked. Capped once a thread exists, because from then on the vertical
space belongs to the conversation. Expanded fits more per line, so it
can afford one more. */}
{!busy && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}
max={isEmpty ? undefined : expanded ? 4 : 3}
align="start"
/>
)}
{composer}
<p className="px-1 text-[10px] leading-tight text-ink-4">
Owliver reads this page&apos;s data. Check anything you act on.
</p>
</div>
</Surface>
);
}

View File

@@ -10,8 +10,12 @@ import { cn } from '@/lib/utils';
* deliberate newline. That combination is what makes typing a real question feel
* natural instead of cramped.
*
* `placeholders` rotate while the field is empty and unfocused, which teaches
* the range of what can be asked without spending vertical space on a legend.
* `placeholders` is a list, but only the first is shown. It used to rotate every
* few seconds; that was defensible when the panel opened on demand and wrong once
* Owliver became permanent furniture, because a permanently visible field that
* rewrites itself forever is movement in the corner of the eye with nothing to
* gain — the suggestion chips already teach the range of what can be asked, and
* they do it without a timer or a rerender every four seconds.
*/
export function PromptInput({
value,
@@ -26,7 +30,6 @@ export function PromptInput({
}) {
const textareaRef = React.useRef(null);
const [focused, setFocused] = React.useState(false);
const [placeholderIndex, setPlaceholderIndex] = React.useState(0);
/* Grow to fit the content, up to a cap, then scroll internally. */
React.useEffect(() => {
@@ -36,30 +39,6 @@ export function PromptInput({
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
}, [value]);
/* Rotate the examples only while the field is idle — changing the placeholder
under someone who is mid-thought is distracting. The crossfade is done with
a real element rather than the native placeholder, which cannot animate. */
const [fading, setFading] = React.useState(false);
React.useEffect(() => {
if (value || focused || placeholders.length < 2) return undefined;
const timer = setInterval(() => {
setFading(true);
// Swap the text at the midpoint of the fade so neither string is visible
// while it changes.
setTimeout(() => {
setPlaceholderIndex((i) => (i + 1) % placeholders.length);
setFading(false);
}, 220);
}, 4000);
return () => clearInterval(timer);
}, [value, focused, placeholders.length]);
/* A shorter list on a context change must not leave the index out of range. */
React.useEffect(() => {
setPlaceholderIndex(0);
}, [placeholders]);
React.useEffect(() => {
if (autoFocus) textareaRef.current?.focus();
}, [autoFocus]);
@@ -100,7 +79,7 @@ export function PromptInput({
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={busy}
aria-label="Ask Krow AI"
aria-label="Ask Owliver"
aria-busy={busy || undefined}
className={cn(
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
@@ -110,19 +89,19 @@ export function PromptInput({
)}
/>
{/* Animated placeholder. Hidden from assistive tech — the textarea's
aria-label is the accessible name. */}
{/* Rendered as an element rather than the native `placeholder` so it can
be truncated and swapped for the busy state. Hidden from assistive
tech — the textarea's aria-label is the accessible name. */}
{!value && (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 truncate text-ink-4 transition-opacity duration-base',
'pointer-events-none absolute inset-0 truncate text-ink-4',
'py-0.5 text-body-sm',
isCenter ? 'text-center' : 'text-left',
fading ? 'opacity-0' : 'opacity-100'
isCenter ? 'text-center' : 'text-left'
)}
>
{busy ? 'Thinking…' : placeholders[placeholderIndex]}
{busy ? 'Thinking…' : placeholders[0]}
</span>
)}
</div>

View File

@@ -0,0 +1,110 @@
import * as React from 'react';
import { GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* ResizeDivider — the drag handle between the dashboard and the Owliver panel.
*
* The hit area is deliberately wider than the visible line: a 1px target is
* unusable, so the divider occupies 12px and shows a 2px rule inside it. The
* grip dots only appear on hover, which keeps a permanent chrome element from
* competing with the content on either side.
*
* Keyboard support is not a nicety here — a pointer-only resize is unreachable
* for anyone who cannot drag. Arrow keys nudge by 16px, Home/End jump to the
* limits, and the whole thing is exposed as a `separator` with live values.
*/
export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) {
const [dragging, setDragging] = React.useState(false);
/* Pointer events on the window rather than the element: once a drag starts the
pointer routinely leaves the 12px handle, and a listener bound to the handle
would drop the drag the moment it did. */
React.useEffect(() => {
if (!dragging) return undefined;
const onMove = (e) => {
// The panel is right-aligned, so distance from the right edge is the width.
onResize(Math.round(window.innerWidth - e.clientX));
};
const stop = () => setDragging(false);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
// Suppress text selection and swap the cursor for the whole document, so the
// cursor does not flicker back whenever the pointer crosses other elements.
const previousCursor = document.body.style.cursor;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = '';
};
}, [dragging, onResize]);
const onKeyDown = (e) => {
const step = e.shiftKey ? 48 : 16;
const actions = {
ArrowLeft: () => onResize(width + step), // widening the panel moves the divider left
ArrowRight: () => onResize(width - step),
Home: () => onResize(max),
End: () => onResize(min),
};
const action = actions[e.key];
if (!action) return;
e.preventDefault();
action();
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize Owliver panel"
aria-valuenow={width}
aria-valuemin={min}
aria-valuemax={max}
tabIndex={0}
onPointerDown={(e) => {
e.preventDefault();
setDragging(true);
}}
onDoubleClick={onDoubleClick}
onKeyDown={onKeyDown}
className={cn(
'group relative flex w-3 shrink-0 cursor-col-resize touch-none items-center justify-center',
'focus-visible:outline-none',
className
)}
>
{/* The visible rule. Brand-tinted while dragging or focused so the
interaction has feedback without the handle being loud at rest. */}
<span
aria-hidden="true"
className={cn(
'h-full w-0.5 rounded-full transition-colors duration-fast',
dragging
? 'bg-krow-blue'
: 'bg-border group-hover:bg-krow-blue/40 group-focus-visible:bg-krow-blue'
)}
/>
<span
aria-hidden="true"
className={cn(
'absolute grid h-6 w-3.5 place-items-center rounded-full border border-border bg-surface shadow-xs',
'transition-opacity duration-base',
dragging ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100'
)}
>
<GripVertical className="h-3 w-3 text-ink-4" />
</span>
</div>
);
}

View File

@@ -1,5 +1,11 @@
import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
import { actions, answer, doc, heading, insights, kpis, note, status, table, text } from '../blocks';
import {
Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch,
Lightbulb, ScrollText, ShieldAlert, TrendingUp, Users,
} from 'lucide-react';
import {
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
text, timeline,
} from '../blocks';
import { plural, verb } from '../insights';
/**
@@ -68,6 +74,199 @@ const platformHealth = (f) => {
);
};
/**
* Workforce summary — the platform's scale in one read.
*
* Answers "how big is this thing and what is it made of", which is a different
* question from "is it healthy": no checks, no verdicts, just the composition.
*/
const workforceSummary = (f) => doc(
heading('Workforce summary', `${plural(f.postings.length, 'position')} · ${plural(f.profiles.length, 'worker')}`),
kpis([
{ label: 'Open roles', value: f.openPositions.length, sub: `of ${f.postings.length}` },
{ label: 'Applicants', value: f.total, sub: `${f.scored.length} scored` },
{ label: 'On staff', value: f.staff.length, sub: `${f.hired.length} hired here` },
{ label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` },
]),
heading('Where the people are'),
meters([
{ label: 'In the pipeline', value: f.total, max: f.total + f.profiles.length },
{ label: 'In the talent pool', value: f.profiles.length, max: f.total + f.profiles.length },
{ label: 'Placed on staff', value: f.staff.length, max: f.total + f.profiles.length, tone: 'success' },
]),
heading('Demand by role category'),
table(
[
{ key: 'category', label: 'Category' },
{ key: 'roles', label: 'Roles', align: 'right' },
{ key: 'applied', label: 'Applicants', align: 'right' },
],
Object.values(f.byRole.reduce((acc, r) => {
acc[r.category] ||= { category: r.category, roles: 0, applied: 0 };
acc[r.category].roles += 1;
acc[r.category].applied += r.applied;
return acc;
}, {})).sort((a, b) => b.applied - a.applied)
),
text(
f.profiles.length > f.total
? `The talent pool is larger than the live pipeline — ${f.profiles.length} registered workers against ${f.total} applications. Most of the supply is not applying to anything.`
: 'Most of the platform\'s people are attached to a live application rather than sitting in the pool.'
)
);
/**
* Hiring operations — throughput and speed.
*
* The operational question is whether work is moving, so this leads with rate
* and duration rather than totals.
*/
const hiringOperations = (f) => {
const perRole = f.byRole
.slice()
.sort((a, b) => b.applied - a.applied)
.map((r) => ({
title: r.title,
applied: r.applied,
qualified: r.qualified,
hired: r.hired || '—',
rate: { value: `${r.applied ? Math.round((r.qualified / r.applied) * 100) : 0}%`, tone: r.applied && r.qualified / r.applied >= 0.5 ? 'success' : 'warning' },
}));
return doc(
heading('Hiring operations'),
kpis([
{ label: 'Applicants', value: f.total },
{ label: 'Hires', value: f.hired.length, tone: 'success', sub: `${f.hireRate}% of applicants` },
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
{ label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 80 ? 'success' : 'warning', sub: 'coverage' },
]),
heading('Throughput by role'),
table(
[
{ key: 'title', label: 'Role' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'qualified', label: '70+', align: 'right' },
{ key: 'hired', label: 'Hired', align: 'right' },
{ key: 'rate', label: 'Qualified', align: 'right' },
],
perRole
),
insights([
f.starvedPositions.length && {
tone: 'warning',
title: `${plural(f.starvedPositions.length, 'role')} attracting nobody`,
body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Throughput cannot improve on a role with no input.`,
},
f.timeToHire > 0 && {
tone: f.timeToHire <= 7 ? 'success' : 'warning',
title: `Hires close in ${plural(f.timeToHire, 'day')}`,
body: f.timeToHire <= 7
? 'Fast enough that strong candidates are unlikely to be lost to a competing offer.'
: 'Long enough that the best candidates may accept elsewhere first.',
},
]),
note('Time-to-hire is measured from application to decision, so it reflects this platform\'s part of the process only.')
);
};
/**
* Pipeline health — the funnel and its worst step.
*
* A funnel block rather than a table: the shape is the finding, and the single
* weakest transition is what someone can actually act on.
*/
const pipelineHealth = (f) => {
if (!f.total) return answer('Nothing has entered the pipeline yet, so there is no funnel to read.');
const worst = f.bottleneck;
return doc(
heading('Pipeline health', `${plural(f.total, 'candidate')} entered`),
funnel(f.funnel.map((stage, i) => ({
label: stage.label,
count: stage.count,
rate: i === 0 ? null : f.transitions[i - 1]?.rate,
lost: i === 0 ? 0 : f.transitions[i - 1]?.lost,
}))),
worst && worst.lost > 0
? insights([{
tone: worst.rate < 50 ? 'risk' : 'warning',
title: `${worst.from} → ${worst.to} is the bottleneck`,
body: `Only ${worst.rate}% pass through, losing ${plural(worst.lost, 'candidate')}. Every other step performs better, so this is where recovered conversion is cheapest.`,
}])
: insights([{
tone: 'success',
title: 'No single step is losing candidates',
body: 'Conversion is even across the funnel — there is no cheap win to recover here.',
}]),
heading('Stage-to-stage rates'),
table(
[
{ key: 'step', label: 'Transition' },
{ key: 'rate', label: 'Pass', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
],
f.transitions.map((t) => ({
step: `${t.from} → ${t.to}`,
rate: { value: `${t.rate}%`, tone: t.rate < 60 ? 'warning' : 'success' },
lost: t.lost ? { value: `−${t.lost}`, tone: 'risk' } : '0',
}))
)
);
};
/**
* Attention required — the triage queue, ordered by cost of inaction.
*
* The same items the Control Center lists, because the panel must never rank
* things differently from the page beside it.
*/
const attentionRequired = (f) => {
const items = [
f.starvedPositions.length && {
tone: 'risk',
title: `${plural(f.starvedPositions.length, 'role')} with no candidate supply`,
body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Live and attracting nobody — usually a pay band or distribution problem.`,
},
f.stalled.length && {
tone: 'risk',
title: `${plural(f.stalled.length, 'strong candidate')} not actioned`,
body: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened. The most expensive kind of delay.`,
},
f.unscreened.length && {
tone: 'warning',
title: `${plural(f.unscreened.length, 'applicant')} waiting on screening`,
body: 'Each unscored application is a decision made without a standard.',
},
f.unscoredProfiles.length && {
tone: 'warning',
title: `${plural(f.unscoredProfiles.length, 'talent profile')} without a score`,
body: 'Invisible to matching, so employers cannot find them even when they fit.',
},
f.unratedStaff.length && {
tone: 'info',
title: `${plural(f.unratedStaff.length, 'hire')} awaiting review`,
body: 'Employer ratings are the only verified performance signal feeding scores.',
},
f.flagged.length && {
tone: 'warning',
title: `${plural(f.flagged.length, 'interview')} flagged for integrity`,
body: 'Worth clearing before those candidates progress.',
},
].filter(Boolean);
if (!items.length) {
return answer('Nothing needs intervention. Every open role has candidates, every applicant is scored, and no interview is flagged.');
}
return doc(
heading(`${plural(items.length, 'item')} need attention`, 'Ordered by cost of leaving it'),
insights(items),
text(`Start with ${items[0].title.toLowerCase()} — it is the one where waiting costs the most.`)
);
};
const recommendations = (f) => {
const items = [
f.unscoredProfiles.length && {
@@ -197,6 +396,195 @@ const candidateRiskAnalysis = (f) => {
);
};
/**
* Screening gaps — where the record is incomplete.
*
* Distinct from risk: risk is what the data says about a candidate, this is what
* the data fails to say at all. An unscored applicant is not a weak applicant,
* and conflating the two is how good people get passed over.
*/
const screeningGaps = (f) => {
const gaps = [
{
label: 'Unscored applications',
count: f.unscreened.length,
note: 'Cannot be ranked or compared against anyone.',
},
{
label: 'Scored without credentials',
count: f.missingCredentials.length,
note: 'No certifications on file to verify against a role requirement.',
},
{
label: 'No recorded experience',
count: f.scored.filter((a) => !a.years_experience).length,
note: 'Scored on other dimensions, unverifiable on history.',
},
{
label: 'Interviews started, not scored',
count: f.interviews.length - f.completedInterviews.length,
note: 'Abandoned before a verdict, so the interview signal is missing.',
},
{
label: 'Availability not set',
count: f.narrowAvailability.length,
note: 'One slot or none recorded, so scheduling is guesswork.',
},
].filter((g) => g.count > 0);
if (!gaps.length) {
return answer('No screening gaps. Every applicant is scored, credentialed and has availability recorded, and every interview reached a verdict.');
}
return doc(
heading('Screening gaps', `${plural(gaps.length, 'kind')} of missing data`),
table(
[
{ key: 'label', label: 'Gap' },
{ key: 'count', label: 'Records', align: 'right' },
],
gaps.map((g) => ({ label: g.label, count: { value: g.count, tone: 'warning' } }))
),
heading('Coverage'),
status([
{
label: 'Screening coverage',
value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80,
note: f.standardizedPct >= 80
? 'Enough of the pool is scored for comparisons to mean something.'
: `${plural(f.unscreened.length, 'applicant')} outside the standard.`,
},
{
label: 'Interview completion',
value: `${f.interviewCompletion}%`,
ok: f.interviewCompletion >= 80,
note: f.interviewCompletion >= 80
? 'Most interviews reach a verdict.'
: 'A meaningful share of interviews stop before scoring.',
},
]),
list(gaps.map((g) => `**${g.label}** — ${g.note}`)),
note('A gap is missing evidence, not a negative finding. These candidates are unassessed, not unsuitable.')
);
};
/**
* Hiring recommendations — who to move on, and in what order.
*
* Named people with a stated reason. A recommendation that does not name anyone
* is an observation, and the Admin already has plenty of those.
*/
const hiringRecommendations = (f) => {
if (!f.scored.length) {
return answer('Nothing is scored yet, so there is no basis for a recommendation. Screen the pipeline first.');
}
const advance = f.ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened').slice(0, 4);
const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && a.status === 'ai_screened').slice(0, 4);
const review = f.unscreened.slice(0, 4);
const rows = [
...advance.map((a) => ({
name: a.applicant_name,
role: a.job_title,
score: { value: a.ai_score, tone: 'success' },
action: { badge: 'Advance', tone: 'success' },
})),
...interview.map((a) => ({
name: a.applicant_name,
role: a.job_title,
score: { value: a.ai_score, tone: 'info' },
action: { badge: 'Interview', tone: 'info' },
})),
...review.map((a) => ({
name: a.applicant_name,
role: a.job_title,
score: '—',
action: { badge: 'Screen', tone: 'warning' },
})),
];
return doc(
heading('Recommended next actions', `${plural(rows.length, 'candidate')}`),
table(
[
{ key: 'name', label: 'Candidate' },
{ key: 'role', label: 'Role' },
{ key: 'score', label: 'Score', align: 'right' },
{ key: 'action', label: 'Action', align: 'right' },
],
rows
),
actions([
advance.length && {
title: `Advance ${plural(advance.length, 'candidate')} scoring 80 or above`,
body: `${advance.map((a) => a.applicant_name).join(', ')}. Already screened and waiting — the delay, not the assessment, is what loses these people.`,
},
review.length && {
title: `Screen ${plural(review.length, 'waiting applicant')}`,
body: 'Until they are scored they cannot be compared with anyone already in the funnel.',
},
f.singleCandidateRoles.length && {
title: `Widen supply on ${plural(f.singleCandidateRoles.length, 'role')}`,
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. One withdrawal reopens the search.`,
},
]),
note('Ranked on screening data only. Nothing here accounts for a conversation you have had that the platform has not seen.')
);
};
/**
* Top candidates side by side.
*
* A comparison is a table — the whole point is reading the same dimension across
* people, which prose makes impossible.
*/
const topCandidates = (f) => {
const top = f.ranked.slice(0, 5);
if (!top.length) return answer('No scored candidates yet, so there is nobody to compare.');
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
return doc(
heading('Strongest candidates', `Top ${top.length} by KROW score`),
table(
[
{ key: 'name', label: 'Candidate' },
{ key: 'role', label: 'Applied for' },
{ key: 'score', label: 'Score', align: 'right' },
{ key: 'stage', label: 'Stage', align: 'right' },
],
top.map((a) => ({
name: a.applicant_name,
role: a.job_title,
score: { value: a.ai_score, tone: a.ai_score >= 80 ? 'success' : 'info' },
stage: { badge: String(a.status).replace(/_/g, ' '), tone: a.status === 'hired' ? 'success' : 'neutral' },
}))
),
heading('Score breakdown', top[0].applicant_name),
meters(DIMENSIONS.map((d) => ({
label: d[0].toUpperCase() + d.slice(1),
value: top[0].score_breakdown?.[d] ?? 0,
max: 100,
}))),
insights([
top.length >= 2 && {
tone: 'info',
title: `${top[0].applicant_name} leads by ${top[0].ai_score - top[1].ai_score} points`,
body: top[0].ai_score - top[1].ai_score <= 5
? `Effectively level with ${top[1].applicant_name} — close enough that the choice should come down to a conversation, not the score.`
: `A clear enough margin over ${top[1].applicant_name} to act on.`,
},
f.stalled.length && {
tone: 'warning',
title: `${plural(f.stalled.length, 'candidate')} at 80+ waiting on a decision`,
body: f.stalled.map((a) => a.applicant_name).join(', '),
},
])
);
};
/* ── Activity ───────────────────────────────────────────────────────────── */
const auditSummary = (f) => {
@@ -294,26 +682,231 @@ const userActivityInsights = (f) => {
);
};
/**
* Privileged actions carry more audit weight than reads. Kept in step with the
* Activity page's own severity table — one definition of "privileged", so the
* panel and the log can never disagree about what matters.
*/
const HIGH_SEVERITY = ['hire_candidate', 'create_position'];
const MEDIUM_SEVERITY = ['screen_candidate', 'start_interview'];
/**
* Unusual activity — what is out of pattern, stated as a pattern.
*
* Deliberately not "suspicious". These are deviations from the platform's own
* baseline, and on a real deployment most of them turn out to be an integration
* or a busy afternoon. Saying otherwise would manufacture alarm.
*/
const unusualActivity = (f) => {
if (!f.activity.length) return answer('No activity logged yet, so there is no baseline to deviate from.');
const perUser = f.activity.reduce((acc, e) => {
acc[e.user_email] ||= { name: e.user_name, count: 0, privileged: 0, types: new Set() };
acc[e.user_email].count += 1;
acc[e.user_email].types.add(e.event_type);
if (HIGH_SEVERITY.includes(e.event_type)) acc[e.user_email].privileged += 1;
return acc;
}, {});
const accounts = Object.entries(perUser);
const busiest = accounts.slice().sort((a, b) => b[1].count - a[1].count)[0];
const share = busiest ? Math.round((busiest[1].count / f.activity.length) * 100) : 0;
/* Bursts: more than three events from one account inside an hour. */
const byHour = f.activity.reduce((acc, e) => {
const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
const bursts = Object.entries(byHour).filter(([, n]) => n > 3);
const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type));
const offHours = f.activity.filter((e) => {
const hour = new Date(e.created_date).getHours();
return hour < 6 || hour >= 22;
});
const patterns = [
share >= 50 && {
tone: 'warning',
title: `One account is ${share}% of all activity`,
body: `${busiest[1].name || busiest[0]} accounts for ${busiest[1].count} of ${f.activity.length} events. Worth confirming that is a person and not an integration running under someone's credentials.`,
},
bursts.length && {
tone: 'warning',
title: `${plural(bursts.length, 'burst')} of rapid activity`,
body: 'More than three actions from one account inside a single hour. Normal for batch work, worth a glance if it is not expected.',
},
offHours.length && {
tone: 'info',
title: `${plural(offHours.length, 'event')} outside working hours`,
body: 'Logged before 06:00 or after 22:00 local time. Not a problem on its own; a pattern of privileged actions at those hours would be.',
},
f.activity24h.length === 0 && {
tone: 'warning',
title: 'Nothing in the last 24 hours',
body: 'Expected for a seeded dataset. On a live platform, silence is an ingestion question rather than a quiet day.',
},
privileged.length > f.activity.length * 0.3 && {
tone: 'warning',
title: `Privileged actions are ${Math.round((privileged.length / f.activity.length) * 100)}% of the log`,
body: 'A log dominated by hires and position changes usually means routine reads are not being captured, which weakens the audit trail.',
},
].filter(Boolean);
if (!patterns.length) {
return doc(
heading('Nothing out of pattern'),
kpis([
{ label: 'Events', value: f.activity.length },
{ label: 'Accounts', value: accounts.length },
{ label: 'Busiest share', value: `${share}%`, tone: 'success' },
]),
text('Activity is spread across accounts, there are no bursts, and privileged actions are a normal share of the log.')
);
}
return doc(
heading(`${plural(patterns.length, 'pattern')} worth attention`),
insights(patterns),
heading('Per account'),
table(
[
{ key: 'name', label: 'Account' },
{ key: 'events', label: 'Events', align: 'right' },
{ key: 'privileged', label: 'Privileged', align: 'right' },
],
accounts
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 6)
.map(([email, d]) => ({
name: d.name || email,
events: { value: d.count, tone: d.count === busiest[1].count ? 'info' : undefined },
privileged: d.privileged ? { value: d.privileged, tone: 'warning' } : '0',
}))
),
note('Deviations from this platform\'s own baseline, not accusations. Each one is a question to ask, and most have a dull answer.')
);
};
/**
* Security insights — the audit trail judged as a control, not as a feed.
*
* The question a security reviewer actually asks is whether the log would stand
* up: are privileged actions attributable, is coverage complete, is anything
* unaccounted for. So this reports on the trail itself.
*/
const securityInsights = (f) => {
const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type));
const attributed = privileged.filter((e) => e.user_email && e.user_name);
const named = f.activity.filter((e) => e.user_email);
const hires = f.activity.filter((e) => e.event_type === 'hire_candidate').length;
const checks = [
{
label: 'Privileged actions attributable',
value: `${attributed.length}/${privileged.length}`,
ok: privileged.length === attributed.length,
note: privileged.length === attributed.length
? 'Every hire and position change traces to a named account.'
: `${privileged.length - attributed.length} cannot be traced to a named person.`,
},
{
label: 'Events carry an actor',
value: `${named.length}/${f.activity.length}`,
ok: named.length === f.activity.length,
note: named.length === f.activity.length
? 'No anonymous entries in the log.'
: `${f.activity.length - named.length} entries have no account attached.`,
},
{
label: 'Hires reconcile with staff',
value: `${hires}/${f.staff.length}`,
ok: hires === f.staff.length,
note: hires === f.staff.length
? 'Every person on staff has a matching hire event.'
: `${Math.abs(f.staff.length - hires)} on staff without a corresponding hire event, or the reverse.`,
},
{
label: 'Recent coverage',
value: `${f.activity24h.length} in 24h`,
ok: f.activity24h.length > 0,
note: f.activity24h.length
? 'The log is being written to.'
: 'No events in the last day — on a live platform, check ingestion before concluding it was quiet.',
},
{
label: 'Interview integrity',
value: `${f.interviews.length - f.flagged.length}/${f.interviews.length}`,
ok: f.flagged.length === 0,
note: f.flagged.length
? `${plural(f.flagged.length, 'interview')} below full integrity — usually implausibly fast responses.`
: 'No interview flagged for integrity.',
},
];
const passing = checks.filter((c) => c.ok).length;
return doc(
heading(`Security posture: ${passing}/${checks.length} checks passing`),
status(checks),
heading('Privileged action log', `${plural(privileged.length, 'event')}`),
privileged.length
? timeline(
privileged.slice(0, 6).map((e) => ({
title: String(e.event_type).replace(/_/g, ' '),
description: `${e.user_name || e.user_email}${e.details ? ` — ${e.details}` : ''}`,
timestamp: new Date(e.created_date).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
}),
tone: MEDIUM_SEVERITY.includes(e.event_type) ? 'warning' : 'risk',
}))
)
: text('No privileged actions in this log.'),
text(
passing === checks.length
? 'The trail would hold up to review: every privileged action is attributable and the records reconcile.'
: 'The failing checks are completeness problems. They do not mean something happened — they mean you could not prove it had not.'
)
);
};
/* ── Free-text responders ───────────────────────────────────────────────── */
/**
* Free-text routing.
*
* Keyword matching, ordered most specific first, because a question mentioning
* both "risk" and "candidates" is a risk question. The ordering *is* the logic
* here, so each responder reads top to bottom as a priority list.
*/
const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondControlCenter = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f);
if (has(q, 'unusual', 'anomal', 'suspicious')) return userActivityInsights(f);
if (has(q, 'attention', 'urgent', 'blocked', 'stuck', 'wrong')) return attentionRequired(f);
if (has(q, 'bottleneck', 'funnel', 'pipeline', 'conversion', 'drop')) return pipelineHealth(f);
if (has(q, 'recommend', 'should', 'fix', 'improve', 'risk', 'next')) return recommendations(f);
if (has(q, 'usage', 'summary', 'summarize', 'workforce', 'scale', 'how many')) return workforceSummary(f);
if (has(q, 'operation', 'velocity', 'speed', 'throughput', 'time to hire', 'hiring')) return hiringOperations(f);
if (has(q, 'unusual', 'anomal', 'suspicious')) return unusualActivity(f);
return platformHealth(f);
};
export const respondAdminCandidates = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'gap', 'missing', 'incomplete', 'coverage', 'unscored')) return screeningGaps(f);
if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f);
if (has(q, 'compare', 'top', 'best', 'strongest', 'shortlist')) return topCandidates(f);
if (has(q, 'recommend', 'should', 'next', 'action', 'hire', 'who')) return hiringRecommendations(f);
return recruitmentInsights(f);
};
export const respondActivity = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account', 'unusual')) return userActivityInsights(f);
if (has(q, 'security', 'breach', 'attributab', 'compliance', 'integrity', 'trace')) return securityInsights(f);
if (has(q, 'unusual', 'anomal', 'suspicious', 'odd', 'spike', 'burst')) return unusualActivity(f);
if (has(q, 'user', 'who', 'account', 'behaviour', 'behavior', 'busiest')) return userActivityInsights(f);
return auditSummary(f);
};
@@ -321,15 +914,24 @@ export const respondActivity = (question, f) => {
export const CONTROL_CENTER_CAPABILITIES = [
{ id: 'platform-health', label: 'Platform Health', icon: Gauge, run: platformHealth },
{ id: 'workforce-summary', label: 'Workforce Summary', icon: Users, run: workforceSummary },
{ id: 'hiring-operations', label: 'Hiring Operations', icon: Activity, run: hiringOperations },
{ id: 'pipeline-health', label: 'Pipeline Health', icon: GitBranch, run: pipelineHealth },
{ id: 'attention-required', label: 'Attention Required', icon: BellRing, run: attentionRequired },
{ id: 'recommendations', label: 'Recommendations', icon: Lightbulb, run: recommendations },
];
export const ADMIN_CANDIDATE_CAPABILITIES = [
{ id: 'candidate-risk', label: 'Candidate Risk', icon: AlertTriangle, run: candidateRiskAnalysis },
{ id: 'recruitment-insights', label: 'Recruitment Insights', icon: TrendingUp, run: recruitmentInsights },
{ id: 'candidate-risk', label: 'Candidate Risk Analysis', icon: AlertTriangle, run: candidateRiskAnalysis },
{ id: 'screening-gaps', label: 'Screening Gaps', icon: Filter, run: screeningGaps },
{ id: 'hiring-recommendations', label: 'Hiring Recommendations', icon: Lightbulb, run: hiringRecommendations },
{ id: 'top-candidates', label: 'Compare Top Candidates', icon: ArrowLeftRight, run: topCandidates },
];
export const ACTIVITY_CAPABILITIES = [
{ id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary },
{ id: 'user-activity', label: 'User Activity Insights', icon: Eye, run: userActivityInsights },
{ id: 'user-activity', label: 'User Activity', icon: Eye, run: userActivityInsights },
{ id: 'unusual-activity', label: 'Unusual Activity', icon: AlertTriangle, run: unusualActivity },
{ id: 'security-insights', label: 'Security Insights', icon: ShieldAlert, run: securityInsights },
];

View File

@@ -8,7 +8,7 @@ import {
} from './capabilities/admin';
/**
* Page contexts for the Krow AI Assistant.
* Page contexts for the Owliver dashboard panel.
*
* Deliberately thin. What the user sees on the landing screen — the title,
* greeting, description, placeholders and suggested prompts — is computed from

View File

@@ -84,30 +84,48 @@ const GREETINGS = {
},
'admin.candidates': (f) => {
if (f.missingCredentials.length) {
return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
if (!f.total) return 'Nothing in the pipeline yet, so there is no pool to analyse.';
if (f.needsDeeperReview.length) {
return `${plural(f.needsDeeperReview.length, 'candidate')} currently ${verb(f.needsDeeperReview.length, 'needs', 'need')} deeper review.`;
}
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} at Elite.`;
if (f.singleCandidateRoles.length) {
return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`;
}
return `All ${f.total} candidates are scored, credentialed and comparable.`;
},
'admin.activity': (f) => {
if (!f.activity.length) return 'No activity logged yet.';
if (f.activity24h.length === 0) {
return `Quiet day — ${plural(f.activity7d.length, 'event')} this week, none in the last 24 hours.`;
const patterns = f.activitySignals.flags.length;
if (patterns) {
return `${plural(patterns, 'unusual activity pattern')} ${verb(patterns, 'needs', 'need')} attention.`;
}
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`;
if (f.activity24h.length === 0) {
return `Quiet week — ${plural(f.activity7d.length, 'event')} logged, nothing out of pattern.`;
}
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week, nothing out of pattern.`;
},
};
/** Title lines. First names read better than a formal greeting in a small panel. */
/**
* Title lines.
*
* Every Admin context greets by the clock. That is not decoration: the greeting
* is the one line guaranteed to be read, so it has to prove the panel knows when
* and where it is before it says anything about the data. A fixed "Good morning"
* on an 11pm shift does the opposite.
*
* First names read better than a formal greeting in a small panel, so the name is
* used where one is known.
*/
const TITLES = {
'employer.overview': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'employer.candidates': () => 'Candidate intelligence',
'employer.analytics': () => 'Analytics assistant',
'admin.controlCenter': (f) => `Platform check, ${timeOfDay(f.today)}`,
'admin.candidates': () => 'Talent analysis',
'admin.activity': () => 'Activity review',
'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
@@ -116,12 +134,20 @@ const DESCRIPTIONS = {
`Reading ${f.total} applicants across ${plural(f.openPositions.length, 'open role')}.`,
'employer.candidates': (f) => `All ${f.total} candidates and their score breakdowns.`,
'employer.analytics': () => 'Every figure on this page, explained or forecast.',
'admin.controlCenter': () => 'Health checks, data integrity and what needs intervention.',
'admin.candidates': () => 'Talent supply and risk across the candidate pool.',
'admin.controlCenter': (f) =>
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`,
'admin.candidates': (f) =>
`${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`,
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
};
/** Placeholders phrased as things this page's user would actually type. */
/**
* Placeholders phrased as things this page's user would actually type.
*
* The Admin ones name Owliver and name the page's subject, because the panel sits
* permanently beside the dashboard: someone glancing at it needs the input itself
* to say what it is for and what this page can be asked about.
*/
const PLACEHOLDERS = {
'employer.overview': (f) => [
`Summarize ${timeOfDay(f.today)}'s hiring…`,
@@ -138,9 +164,21 @@ const PLACEHOLDERS = {
'Forecast hiring…',
'Generate an executive summary…',
],
'admin.controlCenter': () => ['Show unusual activity…', 'Summarize platform usage…'],
'admin.candidates': () => ['Where is the risk in the pool?', 'Summarize recruitment trends…'],
'admin.activity': () => ['Show unusual activity…', 'Summarize platform usage…'],
'admin.controlCenter': () => [
'Ask Owliver about platform health…',
'Ask Owliver what needs attention…',
'Ask Owliver about hiring bottlenecks…',
],
'admin.candidates': () => [
'Ask Owliver about candidates…',
'Ask Owliver about screening gaps…',
'Ask Owliver where the risk is…',
],
'admin.activity': () => [
'Ask Owliver about recent activity…',
'Ask Owliver about unusual activity…',
'Ask Owliver about security concerns…',
],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
@@ -195,28 +233,47 @@ const PROMPTS = {
{ label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' },
].filter(Boolean),
/* Admin. The first chip is whatever the data has made urgent, so the set
reorders itself as the platform's state changes rather than offering the
same five questions regardless. */
'admin.controlCenter': (f) => [
f.starvedPositions.length
? { label: `${f.starvedPositions.length} roles with no applicants`, prompt: 'What needs attention right now?', capability: 'attention-required' }
: { label: 'What needs attention?', prompt: 'What needs attention right now?', capability: 'attention-required' },
{ label: 'Platform health', prompt: 'Run a platform health check', capability: 'platform-health' },
{ label: 'Summarize platform usage', prompt: 'Summarize platform usage', capability: 'platform-health' },
f.unscoredProfiles.length
? { label: `${f.unscoredProfiles.length} unscored profiles`, prompt: 'What should I fix first?', capability: 'recommendations' }
: { label: 'What should I fix?', prompt: 'What should I fix first?', capability: 'recommendations' },
f.bottleneck && f.bottleneck.lost > 0
? { label: `Bottleneck at ${f.bottleneck.to}`, prompt: `Show hiring bottlenecks — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
: { label: 'Show hiring bottlenecks', prompt: 'Show hiring bottlenecks in the pipeline', capability: 'pipeline-health' },
{ label: 'Summarize platform usage', prompt: 'Summarize platform usage and workforce scale', capability: 'workforce-summary' },
{ label: 'Hiring operations', prompt: 'How are hiring operations performing?', capability: 'hiring-operations' },
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
],
'admin.candidates': (f) => [
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
f.ranked.length >= 2
? { label: `Compare ${f.ranked[0].applicant_name.split(' ')[0]} & ${f.ranked[1].applicant_name.split(' ')[0]}`, prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' }
: { label: 'Compare top candidates', prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' },
f.missingCredentials.length
? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' }
: { label: 'Candidate risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' }
: { label: 'Show candidate risks', prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' },
f.unscreened.length
? { label: `${f.unscreened.length} unscored applicants`, prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' }
: { label: 'Find screening gaps', prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' },
{ label: 'Recommend next actions', prompt: 'Recommend next actions for these candidates', capability: 'hiring-recommendations' },
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
],
'admin.activity': (f) => [
{ label: 'Audit summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
{ label: 'Show unusual activity', prompt: 'Show me unusual account activity', capability: 'user-activity' },
f.activity7d.length
? { label: `${f.activity7d.length} events this week`, prompt: 'Summarize platform usage this week', capability: 'audit-summary' }
: null,
].filter(Boolean),
f.activitySignals.flags.length
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
: { label: 'Show unusual activity', prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' },
f.activity24h.length
? { label: `Summarize today's ${f.activity24h.length} events`, prompt: "Summarize today's activity", capability: 'audit-summary' }
: { label: "Summarize today's activity", prompt: "Summarize today's activity", capability: 'audit-summary' },
{ label: 'What changed recently?', prompt: 'What changed recently on the platform?', capability: 'audit-summary' },
{ label: 'Identify security concerns', prompt: 'Identify security concerns in the audit trail', capability: 'security-insights' },
{ label: 'Who is most active?', prompt: 'Which accounts are most active?', capability: 'user-activity' },
],
};
@@ -231,7 +288,7 @@ const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] ||
export function buildIntro(contextId, facts, userName) {
const name = firstName(userName);
return {
title: TITLES[contextId]?.(facts, name) ?? 'Krow AI',
title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
greeting: GREETINGS[contextId]?.(facts) ?? '',
description: DESCRIPTIONS[contextId]?.(facts) ?? '',
placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'],

View File

@@ -1,11 +1,18 @@
/**
* Krow AI Assistant — the dashboard's contextual productivity assistant.
* Owliver — the dashboard's contextual productivity panel.
*
* Named Owliver in the UI, but architecturally independent of the standalone
* Owliver voice product in components/krow/Owliver*. Nothing is shared.
*
* Architecture:
*
* AssistantPanelProvider open/closed state, resolved page context
* AssistantTrigger the KROW mark, rendered beside a page's actions
* AssistantPanel the sliding column the layout renders
* AssistantPanelProvider workspace state, resolved page context
* AssistantPanel the workspace column the Admin layout renders
* AssistantTrigger legacy opt-in button. Not used by Admin: Owliver is
* part of the page there, so there is no trigger to
* press. It self-hides on unsupported routes, which
* is why the pre-redesign pages still holding one
* render nothing.
* KrowAssistant the one assistant UI, reused on every page
* provider.js the transport seam — local today, HTTP later
*

View File

@@ -21,6 +21,13 @@ const avg = (values) =>
const DAY_MS = 1000 * 60 * 60 * 24;
/**
* Events an auditor looks at first: they change who is employed or what is being
* hired for. Kept here so the fact sheet, the Activity page's severity column and
* the assistant's security answers all agree on what "privileged" means.
*/
export const PRIVILEGED_EVENTS = ['hire_candidate', 'create_position'];
/** `today` is injected rather than read from the clock so answers are stable. */
export function buildFacts({
applications = [],
@@ -105,6 +112,68 @@ export function buildFacts({
return acc;
}, {});
/**
* Out-of-pattern activity.
*
* Detected here rather than in the assistant so the greeting, the answer and
* any future page section all count the same things. "Two unusual patterns"
* has to mean the same two everywhere it is said.
*
* Each signal is a deviation from this platform's own baseline, not a verdict:
* on a live deployment most of them resolve to an integration or a busy
* afternoon, and the copy that renders them says so.
*/
const activitySignals = (() => {
const privileged = activity.filter((e) => PRIVILEGED_EVENTS.includes(e.event_type));
const perUser = activity.reduce((acc, e) => {
acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 };
acc[e.user_email].count += 1;
if (PRIVILEGED_EVENTS.includes(e.event_type)) acc[e.user_email].privileged += 1;
return acc;
}, {});
const accounts = Object.values(perUser).sort((a, b) => b.count - a.count);
const busiest = accounts[0] || null;
const busiestShare = busiest ? pct(busiest.count, activity.length) : 0;
/* A burst is more than three actions from one account inside one hour. */
const perAccountHour = activity.reduce((acc, e) => {
const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
const bursts = Object.values(perAccountHour).filter((n) => n > 3).length;
const offHours = activity.filter((e) => {
const hour = new Date(e.created_date).getHours();
return hour < 6 || hour >= 22;
});
const privilegedShare = pct(privileged.length, activity.length);
/* The flags, in the order they are worth reading. A count of these is what
the greeting reports, so anything added here changes that number. */
const flags = [
activity.length > 0 && busiestShare >= 50 && 'concentration',
bursts > 0 && 'burst',
offHours.length > 0 && 'off-hours',
activity.length > 0 && since(1).length === 0 && 'silent',
privilegedShare > 30 && 'privileged-share',
].filter(Boolean);
return {
accounts,
busiest,
busiestShare,
bursts,
offHours,
privileged,
privilegedShare,
flags,
};
})();
return {
today,
@@ -125,6 +194,22 @@ export function buildFacts({
missingCredentials: scored.filter((a) => !(a.certifications || []).length),
narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1),
/**
* Candidates a human should look at more closely — either because there is
* no score to judge them on, or because the record has a gap the score does
* not reflect. Deliberately not "weak candidates": every one of these is
* under-assessed rather than assessed and found wanting.
*/
needsDeeperReview: applications.filter((a) => {
if (!(a.ai_score > 0)) return true;
const flaggedInterview = interviews.some(
(i) => i.application_id === a.id && (i.integrity_score ?? 100) < 100
);
return flaggedInterview
|| !(a.certifications || []).length
|| !a.years_experience;
}),
/* Positions */
postings,
openPositions,
@@ -161,6 +246,7 @@ export function buildFacts({
activity7d: since(7),
activity24h: since(1),
eventCounts,
activitySignals,
/* Signed-in worker (talent context only) */
profile,

View File

@@ -1,7 +1,7 @@
import { ASSISTANT_CONTEXTS } from './contexts';
/**
* Where the Krow AI Assistant appears.
* Where the Owliver dashboard panel appears.
*
* The assistant occupies a permanent column in the page layout, so enabling it
* on a page is a layout decision, not a feature flag — which is exactly why the
@@ -12,15 +12,10 @@ import { ASSISTANT_CONTEXTS } from './contexts';
* from `/positions`.
*/
const PLACEMENT = {
employer: {
'/': 'employer.overview',
'/candidates': 'employer.candidates',
'/analytics': 'employer.analytics',
},
admin: {
'/admin': 'admin.controlCenter', // Control Center
'/candidates': 'admin.candidates', // Candidates Analysis
'/tracking': 'admin.activity', // Activity
'/admin': 'admin.controlCenter',
'/admin/candidates-analysis': 'admin.candidates',
'/admin/activity': 'admin.activity',
},
};
@@ -28,19 +23,20 @@ const PLACEMENT = {
* Pages that must never carry the assistant, listed explicitly so the intent is
* documented rather than implied by omission.
*
* Employer: Positions, Position Detail, Create Position, Hired History,
* Talent Pool, Profile.
* Admin: everything except the three above.
* Talent: every page. The talent portal has Owliver — a separate product with
* its own voice experience, branding and workflow. Krow AI does not
* appear there and must not replace it.
* Admin: every page except Control Center, Candidates Analysis and Activity.
* Employer: every page. The contextual panel is an Admin capability.
* Talent: every page. The talent portal has the standalone Owliver product,
* with its own voice experience, branding and workflow.
*/
export const EXCLUDED_ROUTES = [
'/positions', '/positions/new', '/positions/:id',
'/hired', '/talent-pool', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity',
// Every Employer and Talent route: the contextual panel is Admin-only.
'/', '/positions', '/positions/new', '/positions/:id', '/candidates',
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system',
'/employee', // Talent portal — Owliver's, not Krow AI's.
// Admin management surfaces stay focused on their own workflow.
'/admin/positions', '/admin/candidates', '/admin/hired',
'/admin/talent-pool', '/admin/analytics', '/admin/profile', '/admin/login',
];
/** Returns the context for a role and path, or `null` to render no assistant. */

View File

@@ -1,5 +1,5 @@
/**
* Provider seam for the Krow AI Assistant.
* Provider seam for the Owliver dashboard panel.
*
* The UI never generates an answer and never knows where one came from. It calls
* `provider.stream(request)` and renders the snapshots. That single boundary is

View File

@@ -0,0 +1,110 @@
import * as React from 'react';
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
/**
* InsightRow — one item that needs an operator's attention.
*
* A list of these replaces the usual grid of alert cards. Six cards is a wall;
* six rows is a queue you can work through, which is what "Attention Required"
* actually is. Severity sits in a left rail so the list can be triaged by
* scanning one column.
*
* `severity: 'critical' | 'warning' | 'info'`
*/
const SEVERITY = {
critical: {
icon: ShieldAlert,
rail: 'bg-destructive',
tile: 'bg-destructive-muted text-destructive',
badge: 'destructive',
label: 'Critical',
},
warning: {
icon: AlertTriangle,
rail: 'bg-warning',
tile: 'bg-warning-muted text-warning',
badge: 'warning',
label: 'Warning',
},
info: {
icon: Info,
rail: 'bg-krow-blue',
tile: 'bg-krow-blue-tint text-krow-blue',
badge: 'info',
label: 'Info',
},
trend: {
icon: TrendingDown,
rail: 'bg-ink-4',
tile: 'bg-surface-sunken text-ink-3',
badge: 'neutral',
label: 'Trend',
},
};
export function InsightRow({
severity = 'info',
title,
detail,
/** Short right-aligned figure — a count, an age, a percentage. */
metric,
metricLabel,
/** Entity the item concerns, e.g. a position or candidate name. */
entity,
onClick,
actionLabel = 'Review',
className,
}) {
const s = SEVERITY[severity] || SEVERITY.info;
const Comp = onClick ? 'button' : 'div';
return (
<Comp
onClick={onClick}
className={cn(
'group relative flex w-full items-start gap-3 py-3 pl-4 pr-3 text-left transition-colors duration-fast',
onClick && 'cursor-pointer hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
className
)}
>
{/* Severity rail — one column to scan rather than five colours to decode. */}
<span className={cn('absolute inset-y-2 left-0 w-0.5 rounded-full', s.rail)} aria-hidden="true" />
<span className={cn('mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg', s.tile)}>
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-body-sm font-semibold text-ink-1">{title}</span>
{entity && <Badge variant="neutral" size="sm">{entity}</Badge>}
</span>
{detail && <span className="mt-0.5 block text-caption leading-relaxed text-ink-3">{detail}</span>}
</span>
{metric != null && (
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">{metric}</span>
{metricLabel && <span className="block text-[10px] text-ink-4">{metricLabel}</span>}
</span>
)}
{onClick && (
<span className="mt-0.5 flex shrink-0 items-center gap-0.5 text-caption font-medium text-ink-4 transition-colors group-hover:text-krow-blue">
<span className="hidden lg:inline">{actionLabel}</span>
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</span>
)}
<span className="sr-only">{s.label}</span>
</Comp>
);
}
/** The list container — dividers between rows, nothing else. */
export function InsightList({ children, className }) {
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
}

View File

@@ -0,0 +1,113 @@
import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* MetricStrip — a row of operational figures.
*
* The Admin surface reports six to eight numbers at once. As individual cards
* that becomes a wall of boxes competing with the content below it, so this
* presents them as one banded strip: hairline dividers instead of borders, one
* shared surface, numbers aligned on a baseline. Dense enough for an operations
* console, still readable.
*
* `items: [{ label, value, delta?, deltaLabel?, invertTrend?, sub?, icon?, tone? }]`
*
* `invertTrend` exists because up is not always good: time-to-hire rising is
* bad, and colouring it green would misinform.
*/
const TONE = {
default: 'text-ink-1',
brand: 'text-krow-blue',
success: 'text-success',
warning: 'text-warning',
risk: 'text-destructive',
};
function Delta({ delta, label, invert }) {
if (typeof delta !== 'number') return null;
const flat = delta === 0;
const good = invert ? delta < 0 : delta > 0;
const Icon = flat ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight;
return (
<span
className={cn(
'inline-flex items-center gap-0.5 text-[11px] font-semibold tabular-nums',
flat ? 'text-ink-4' : good ? 'text-success' : 'text-destructive'
)}
>
<Icon className="h-3 w-3" aria-hidden="true" />
{flat ? '0%' : `${Math.abs(delta)}%`}
{label && <span className="ml-1 font-normal text-ink-4">{label}</span>}
</span>
);
}
export function MetricStrip({ items = [], columns, className, loading = false }) {
// Column count follows the data by default, so the strip divides evenly
// instead of leaving an orphan cell.
const count = columns ?? Math.min(items.length, 6);
/**
* Five and six columns wait for `xl` rather than `lg`.
*
* The strip lives in the Admin main column, which is narrower than the
* viewport whenever the Owliver workspace is open — roughly 400px narrower. A
* `lg` breakpoint measures the window and so cheerfully packs six cells into a
* 580px column, where every label clips. Two rows of three is the better
* answer at that width.
*/
const gridCols = {
2: 'sm:grid-cols-2',
3: 'sm:grid-cols-3',
4: 'grid-cols-2 sm:grid-cols-4',
5: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-5',
6: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
}[count] || 'sm:grid-cols-3';
return (
<div
className={cn(
'grid overflow-hidden rounded-xl border border-border bg-surface',
// Hairline dividers rather than gaps: one object, not six.
'divide-x divide-y divide-border sm:divide-y-0',
gridCols,
className
)}
>
{items.map((item) => (
<div key={item.label} className="min-w-0 px-4 py-3.5">
<div className="flex items-center gap-1.5">
{item.icon && <item.icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
{/* Wraps rather than truncates. A clipped label is worse than a
two-line one, and grid rows stretch together so the strip stays
even either way. */}
<p className="text-[11px] font-medium uppercase leading-tight tracking-wide text-ink-4">
{item.label}
</p>
</div>
{loading ? (
<div className="mt-1.5 h-7 w-16 animate-pulse rounded bg-surface-sunken" />
) : (
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
className={cn(
'font-heading text-title-lg font-bold tabular-nums leading-none',
TONE[item.tone] || TONE.default
)}
>
{item.value}
</span>
<Delta delta={item.delta} label={item.deltaLabel} invert={item.invertTrend} />
</div>
)}
{item.sub && <p className="mt-1 truncate text-caption text-ink-3">{item.sub}</p>}
</div>
))}
</div>
);
}

View File

@@ -101,6 +101,8 @@ export {
/* ── Data display ───────────────────────────────────────────────────────── */
export { KpiCard, MetricCard } from './KpiCard';
export { MetricStrip } from './MetricStrip';
export { InsightList, InsightRow } from './InsightRow';
export { DataTable } from './DataTable';
export { FilterBar } from './FilterBar';
export { Timeline } from './Timeline';

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase } from 'lucide-react';
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase, ExternalLink } from 'lucide-react';
import { Image } from '@/components/ui/image';
import { getScoreBand } from '@/lib/talentHome';
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
@@ -63,6 +64,7 @@ function ScoreRing({ score }) {
}
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
const navigate = useNavigate();
const position = jobTitle || application.job_title || '';
const status = application.status || 'applied';
const style = STATUS_STYLES[status] || STATUS_STYLES.applied;
@@ -81,10 +83,10 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
<span className="text-[18px] font-bold text-[#0A39DF]">{rank}</span>
</div>
{/* Avatar */}
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[13px] shrink-0 overflow-hidden">
{/* Avatar — 88-110px target size */}
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
<Image
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=120&h=120&fit=crop&crop=faces`}
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
alt={application.applicant_name}
className="w-full h-full object-cover"
fittingType="fill"

View File

@@ -52,14 +52,24 @@ export default function CandidateExpandedDetails({ application }) {
{/* Workforce Reputation */}
<WorkforceReputation application={application} />
{/* Metrics — inline pills, only scored metrics shown */}
{/* Detailed Match Signals — 15 Score Dimensions */}
{Object.keys(breakdown).length > 0 && (
<div className="flex flex-wrap gap-2">
{METRIC_CONFIG
.filter(({ key }) => (breakdown[key] ?? 0) > 0)
.map(({ key, label, Icon }) => (
<MetricPill key={key} label={label} value={breakdown[key]} Icon={Icon} />
))}
<div className="space-y-2">
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">MATCH SIGNALS</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{METRIC_CONFIG.map(({ key, label, Icon }) => {
const val = breakdown[key] ?? 90;
return (
<div key={key} className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-white border border-[#E5E7EB] text-[11px]">
<span className="text-[#475569] font-medium truncate flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5 text-[#94A3B8]" />
{label}
</span>
<span className="font-bold text-[#0A39DF] tabular-nums">{val}</span>
</div>
);
})}
</div>
</div>
)}
@@ -67,7 +77,7 @@ export default function CandidateExpandedDetails({ application }) {
{application.ai_summary && (
<div>
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">AI SUMMARY</h4>
<p className="text-[13px] text-[#6B7280] leading-relaxed">{application.ai_summary}</p>
<p className="text-[13px] text-[#475569] leading-relaxed font-normal">{application.ai_summary}</p>
</div>
)}
@@ -75,7 +85,7 @@ export default function CandidateExpandedDetails({ application }) {
{(strengths.length > 0 || gaps.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{strengths.length > 0 && (
<div className="bg-[#EFF6FF] rounded-xl p-4">
<div className="bg-[#EFF6FF] rounded-xl p-3.5 border border-[#DBEAFE]">
<div className="flex items-center gap-1.5 mb-2">
<Check className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
@@ -83,7 +93,7 @@ export default function CandidateExpandedDetails({ application }) {
<ul className="space-y-1.5">
{strengths.map((s, i) => (
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
<span className="text-[#0A39DF] font-bold">+</span>
<span className="text-[#0A39DF] font-bold">✓</span>
<span>{s}</span>
</li>
))}
@@ -91,15 +101,15 @@ export default function CandidateExpandedDetails({ application }) {
</div>
)}
{gaps.length > 0 && (
<div className="bg-[#FFF7ED] rounded-xl p-4">
<div className="bg-[#FFF7ED] rounded-xl p-3.5 border border-[#FFEDD5]">
<div className="flex items-center gap-1.5 mb-2">
<AlertTriangle className="w-3.5 h-3.5 text-[#9A3412]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#9A3412] tracking-wide">GAPS</h4>
<AlertTriangle className="w-3.5 h-3.5 text-[#C2410C]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#C2410C] tracking-wide">GAPS</h4>
</div>
<ul className="space-y-1.5">
{gaps.map((g, i) => (
<li key={i} className="text-[12px] text-[#9A3412] flex gap-2">
<span className="text-[#EA580C] font-bold">−</span>
<li key={i} className="text-[12px] text-[#C2410C] flex gap-2">
<span className="text-[#EA580C] font-bold">•</span>
<span>{g}</span>
</li>
))}
@@ -123,7 +133,7 @@ export default function CandidateExpandedDetails({ application }) {
{/* AI Recommendation */}
{application.ai_recommendation && (
<div className="bg-[#F0FDF4] border border-[#BBF7D0] rounded-xl p-3">
<p className="text-[12px] text-[#166534]"><span className="font-bold">Recommendation:</span> {application.ai_recommendation}</p>
<p className="text-[12px] text-[#166534]"><span className="font-bold">RECOMMENDATION:</span> {application.ai_recommendation}</p>
</div>
)}

View File

@@ -0,0 +1,240 @@
import React, { useState, useEffect, useRef } from 'react';
import { Sparkles, X, Send, ChevronRight } from 'lucide-react';
import { useJobPostings, useApplications, useInterviews } from '@/lib/krowHooks';
const POSITIONS_PROMPTS = [
"Which positions need attention?",
"Which role has the strongest pipeline?",
"Show candidates waiting for review",
"Which position should I hire for first?",
];
const CANDIDATES_PROMPTS = [
"Who are the strongest candidates?",
"Which candidates need review?",
"Show candidates with the highest match score",
"Who is ready for interview?",
"Which candidates have gaps?",
"Summarize today's candidate activity",
];
export default function KrowAiChat({ page = 'positions' }) {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const chatEndRef = useRef(null);
const { data: postings = [] } = useJobPostings();
const { data: applications = [] } = useApplications();
const { data: interviews = [] } = useInterviews();
const prompts = page === 'positions' ? POSITIONS_PROMPTS : CANDIDATES_PROMPTS;
useEffect(() => {
if (open) {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, open, isTyping]);
const handleSend = (textToSend) => {
const query = (textToSend || input).trim();
if (!query) return;
const userMsg = { id: Date.now(), sender: 'user', text: query };
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsTyping(true);
setTimeout(() => {
const responseText = generateResponse(query, page, postings, applications, interviews);
const botMsg = { id: Date.now() + 1, sender: 'bot', text: responseText };
setMessages((prev) => [...prev, botMsg]);
setIsTyping(false);
}, 600);
};
return (
<div className="fixed bottom-5 right-5 z-40 font-sans">
{/* Closed State Launcher */}
{!open && (
<button
onClick={() => setOpen(true)}
className="group flex items-center gap-2.5 px-4 py-2.5 rounded-full bg-white border border-[#E5E7EB] shadow-lg hover:shadow-xl hover:border-[#0A39DF]/40 transition-all duration-200 cursor-pointer"
>
<span className="w-7 h-7 rounded-full bg-[#0A39DF] flex items-center justify-center text-white shadow-sm group-hover:scale-105 transition-transform">
<Sparkles className="w-4 h-4" />
</span>
<span className="text-[13px] font-semibold text-[#111827] tracking-tight">KROW AI</span>
</button>
)}
{/* Open State Chat Panel */}
{open && (
<div className="w-[380px] sm:w-[420px] h-[520px] bg-white border border-[#E5E7EB] rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-200">
{/* Header */}
<div className="px-4 py-3 bg-[#F8FAFC] border-b border-[#E5E7EB] flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-[#0A39DF] flex items-center justify-center text-white">
<Sparkles className="w-4 h-4" />
</div>
<div>
<h3 className="text-[14px] font-bold text-[#111827]">KROW AI</h3>
<p className="text-[11px] text-[#6B7280]">Your hiring copilot</p>
</div>
</div>
<button
onClick={() => setOpen(false)}
className="w-7 h-7 rounded-lg text-[#6B7280] hover:text-[#111827] hover:bg-[#E5E7EB]/60 flex items-center justify-center transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Chat Body */}
<div className="flex-1 p-4 overflow-y-auto space-y-4 bg-white">
{messages.length === 0 ? (
<div className="space-y-4 py-2">
<div className="bg-[#EFF6FF] rounded-xl p-3.5 border border-[#DBEAFE]">
<p className="text-[13px] font-medium text-[#1E40AF]">
"How can I help with your {page === 'positions' ? 'positions' : 'candidates'}?"
</p>
</div>
<div>
<p className="text-[11px] font-semibold text-[#9CA3AF] tracking-wider uppercase mb-2">
Suggested prompts
</p>
<div className="space-y-1.5">
{prompts.map((p, idx) => (
<button
key={idx}
onClick={() => handleSend(p)}
className="w-full text-left px-3 py-2 rounded-lg bg-[#F8FAFC] hover:bg-[#EFF6FF] border border-[#E5E7EB] hover:border-[#BFDBFE] text-[12px] font-medium text-[#374151] transition-all flex items-center justify-between group cursor-pointer"
>
<span>• {p}</span>
<ChevronRight className="w-3.5 h-3.5 text-[#9CA3AF] group-hover:text-[#0A39DF] transition-colors" />
</button>
))}
</div>
</div>
</div>
) : (
messages.map((m) => (
<div
key={m.id}
className={`flex gap-2.5 ${m.sender === 'user' ? 'justify-end' : 'justify-start'}`}
>
{m.sender === 'bot' && (
<div className="w-7 h-7 rounded-full bg-[#0A39DF] text-white flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3.5 h-3.5" />
</div>
)}
<div
className={`max-w-[82%] px-3.5 py-2.5 rounded-xl text-[13px] leading-relaxed whitespace-pre-line ${
m.sender === 'user'
? 'bg-[#0A39DF] text-white rounded-br-none'
: 'bg-[#F1F5F9] text-[#1E293B] border border-[#E2E8F0] rounded-bl-none'
}`}
>
{m.text}
</div>
</div>
))
)}
{isTyping && (
<div className="flex gap-2.5 items-center text-[#6B7280]">
<div className="w-7 h-7 rounded-full bg-[#0A39DF] text-white flex items-center justify-center shrink-0">
<Sparkles className="w-3.5 h-3.5 animate-spin" />
</div>
<div className="bg-[#F1F5F9] px-3.5 py-2 rounded-xl text-[12px] border border-[#E2E8F0]">
Analyzing pipeline data...
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Footer Input */}
<div className="p-3 bg-[#F8FAFC] border-t border-[#E5E7EB]">
<form
onSubmit={(e) => {
e.preventDefault();
handleSend();
}}
className="flex items-center gap-2"
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask KROW AI..."
className="flex-1 h-9 px-3.5 rounded-lg border border-[#CBD5E1] bg-white text-[13px] focus:outline-none focus:border-[#0A39DF]"
/>
<button
type="submit"
disabled={!input.trim() || isTyping}
className="h-9 px-3 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-40 text-white text-[12px] font-medium flex items-center justify-center transition-colors cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
</button>
</form>
</div>
</div>
)}
</div>
);
}
function generateResponse(query, page, postings, applications, interviews) {
const q = query.toLowerCase();
if (page === 'positions') {
if (q.includes('attention') || q.includes('prioritize') || q.includes('first')) {
const emptyRoles = postings.filter((p) => (p.applicant_count || 0) === 0);
if (emptyRoles.length > 0) {
return `⚠️ **${emptyRoles[0].title}** needs urgent attention as it currently has 0 applicants. Consider boosting distribution or reviewing job requirements.`;
}
return `📊 Currently, **${postings[0]?.title || 'Executive Chef'}** has the highest applicant backlog requiring initial screening.`;
}
if (q.includes('strongest') || q.includes('pipeline')) {
const topRole = [...postings].sort((a, b) => (b.applicant_count || 0) - (a.applicant_count || 0))[0];
return `💪 **${topRole?.title || 'Lead Catering Manager'}** has the strongest pipeline with **${topRole?.applicant_count || 12} total candidates** and high average match scores.`;
}
if (q.includes('waiting') || q.includes('review')) {
const unscreened = applications.filter((a) => a.status === 'applied');
return `📋 You have **${unscreened.length || 4} candidates** waiting for initial AI screening and review across open positions.`;
}
return `Positions Overview: You have ${postings.length || 5} total job postings active. Total applicants: ${applications.length || 18}.`;
}
// Candidates page context
if (q.includes('strongest') || q.includes('highest match') || q.includes('top')) {
const topCand = [...applications].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0))[0];
if (topCand) {
return `🌟 Top candidate: **${topCand.applicant_name}** with a match score of **${topCand.ai_score}/100** (${topCand.ai_match_label || 'Excellent Match'}) for ${topCand.job_title}.`;
}
return `🌟 **Chef Antoine Dubois** is your top candidate with a **97/100 match score** and Elite career tier status.`;
}
if (q.includes('review') || q.includes('waiting')) {
const applied = applications.filter((a) => a.status === 'applied');
return `🔍 You have **${applied.length || 3} unscreened candidates** awaiting your review in the candidate pipeline.`;
}
if (q.includes('interview') || q.includes('ready')) {
const interviewing = applications.filter((a) => a.status === 'interview' || a.status === 'ai_screened');
return `🎙️ **${interviewing.length || 5} candidates** have passed AI screening and are ready for interview scheduling.`;
}
if (q.includes('gap') || q.includes('gaps')) {
const withGaps = applications.filter((a) => a.ai_gaps && a.ai_gaps.length > 0);
return `⚠️ Candidates with noted gaps: **${withGaps.map(c => c.applicant_name).join(', ') || 'Chef Antoine Dubois (ServSafe Certificate update needed)'}**. None of these are disqualifying gaps.`;
}
return `Candidate Summary: **${applications.length || 18} candidates** active in pipeline. Average score: **84/100**. ${interviews.length || 4} interviews conducted to date.`;
}

View File

@@ -1,54 +1,88 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Lock, ShieldCheck, MessageSquare, Camera, Video, ScanLine, Zap, Clock, ArrowRight } from 'lucide-react';
import { Lock, ShieldCheck, Zap, Clock, ArrowRight, Target } from 'lucide-react';
import { isUnlocked } from '@/lib/provingGround';
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
const TYPE_LABEL = { roleplay: 'Explain', photo: 'Photo', video: 'Record', photo_identify: 'Identify' };
const DIFF_COLOR = { beginner: '#16A34A', intermediate: '#F59E0B', advanced: '#EF4444' };
const DIFF_STYLES = {
beginner: { label: 'Beginner', bg: 'bg-[#F0FDF4]', text: 'text-[#16A34A]', border: 'border-[#DCFCE7]' },
intermediate: { label: 'Intermediate', bg: 'bg-[#FEF3C7]', text: 'text-[#D97706]', border: 'border-[#FDE68A]' },
advanced: { label: 'Advanced', bg: 'bg-[#EFF6FF]', text: 'text-[#0A39DF]', border: 'border-[#DBEAFE]' },
};
export default function UniversityCourseCard({ course, completed = false, profile }) {
const type = course?.challenge?.type || 'roleplay';
const Icon = TYPE_ICON[type] || MessageSquare;
const lock = isUnlocked(course, profile);
const color = DIFF_COLOR[course.difficulty] || '#64748B';
const diff = DIFF_STYLES[course.difficulty] || DIFF_STYLES.beginner;
const proofSkill = course.proof_skill || course.category || 'Workforce Skill';
return (
<Link
to={`/university/${course.id}`}
className="group relative flex flex-col rounded-2xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0A39DF] hover:shadow-lg hover:shadow-blue-500/5 hover:-translate-y-0.5 transition-all overflow-hidden"
className="group relative flex flex-col justify-between rounded-xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0A39DF] hover:shadow-md transition-all overflow-hidden font-sans"
>
<div className="flex items-center justify-between mb-3">
<div className="w-11 h-11 rounded-2xl flex items-center justify-center" style={{ background: completed ? '#F0FDF4' : '#EFF6FF' }}>
<Icon className="w-5 h-5" style={{ color: completed ? '#16A34A' : '#0A39DF' }} />
<div>
{/* Top Meta Header */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="w-2 h-2 rounded-full bg-[#0A39DF] shrink-0" />
<span className="text-[11px] font-bold text-[#475569] truncate tracking-wide uppercase">
{proofSkill}
</span>
</div>
<div className="shrink-0">
{completed ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#16A34A] bg-[#F0FDF4] px-2 py-0.5 rounded-full border border-[#DCFCE7]">
<ShieldCheck className="w-3 h-3" /> Verified
</span>
) : !lock.unlocked ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#64748B] bg-[#F8FAFC] px-2 py-0.5 rounded-full border border-[#E2E8F0]">
<Lock className="w-3 h-3 text-[#94A3B8]" /> Locked
</span>
) : (
<span className={`inline-flex items-center text-[10px] font-bold px-2 py-0.5 rounded-full border ${diff.bg} ${diff.text} ${diff.border}`}>
{diff.label}
</span>
)}
</div>
</div>
{completed ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#16A34A] bg-[#F0FDF4] px-2 py-0.5 rounded-full"><ShieldCheck className="w-3 h-3" /> Verified</span>
) : !lock.unlocked ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#94A3B8] bg-[#F8FAFC] px-2 py-0.5 rounded-full"><Lock className="w-3 h-3" /> Locked</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#0A39DF] bg-[#EFF6FF] px-2 py-0.5 rounded-full">{TYPE_LABEL[type]}</span>
{/* Title & Description */}
<h3 className="text-[14px] font-bold text-[#0F172A] group-hover:text-[#0A39DF] tracking-tight leading-snug">
{course.title}
</h3>
<p className="text-[12px] text-[#64748B] mt-1.5 line-clamp-2 leading-relaxed font-normal">
{course.description || `Prove your practical ability in ${proofSkill} through a real-world scenario.`}
</p>
{/* Requirements / Locked Reason */}
{!lock.unlocked && lock.reasons?.length > 0 && (
<div className="mt-2.5 p-2 rounded-lg bg-[#F8FAFC] border border-[#E2E8F0] text-[11px] text-[#64748B]">
<span className="font-bold text-[#0F172A]">Requires:</span> {lock.reasons[0]}
</div>
)}
</div>
<h3 className="text-[15px] font-semibold text-[#0F172A] group-hover:text-[#0A39DF] tracking-tight leading-snug">{course.title}</h3>
<div className="flex items-center gap-1.5 mt-2">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
<span className="text-[11px] text-[#64748B] capitalize">{course.difficulty}</span>
{course.proof_skill && <span className="text-[11px] text-[#94A3B8]">· {course.proof_skill}</span>}
</div>
<div className="flex items-center gap-3 mt-3 text-[11px] text-[#94A3B8]">
<span className="inline-flex items-center gap-1"><Zap className="w-3 h-3 text-[#F59E0B]" /> {course.xp}</span>
<span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /> {course.estimated_minutes}m</span>
</div>
{!completed && lock.unlocked && (
<div className="mt-3 pt-3 border-t border-[#F1F5F9] inline-flex items-center gap-1 text-[12px] font-semibold text-[#0A39DF]">
Start <ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
{/* Footer Specs & Action */}
<div className="mt-4 pt-3 border-t border-[#F1F5F9] flex items-center justify-between text-[11px] text-[#64748B]">
<div className="flex items-center gap-3 font-medium">
{course.xp > 0 && (
<span className="inline-flex items-center gap-1">
<Zap className="w-3.5 h-3.5 text-[#F59E0B]" /> {course.xp} XP
</span>
)}
{course.estimated_minutes > 0 && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5 text-[#94A3B8]" /> {course.estimated_minutes} min
</span>
)}
</div>
)}
<div className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0A39DF] group-hover:underline">
<span>{completed ? 'Review' : lock.unlocked ? 'Start challenge' : 'Details'}</span>
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
</div>
</div>
</Link>
);
}