ui changes
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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' };
|
||||
|
||||
|
||||
@@ -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'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's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
110
src/components/ai-assistant/ResizeDivider.jsx
Normal file
110
src/components/ai-assistant/ResizeDivider.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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…'],
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
110
src/components/ds/InsightRow.jsx
Normal file
110
src/components/ds/InsightRow.jsx
Normal 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>;
|
||||
}
|
||||
113
src/components/ds/MetricStrip.jsx
Normal file
113
src/components/ds/MetricStrip.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
240
src/components/krow/KrowAiChat.jsx
Normal file
240
src/components/krow/KrowAiChat.jsx
Normal 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.`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user