update agents skill design

This commit is contained in:
2026-08-20 18:18:10 +05:30
parent 161b237695
commit b2e6868824
75 changed files with 14586 additions and 98 deletions

View File

@@ -0,0 +1,180 @@
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, Modal, SearchInput } from '@/components/ds';
import { allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { surfaceFor } from '@/lib/skills/surfaces';
/**
* Attaching skills to an agent.
*
* Reads **the one skill registry** — the same `allSkills` the Skills page and
* Owliver itself read. There is deliberately no separate list for agents: a
* second one would drift, and an agent would end up offering a skill the
* runtime does not have.
*
* Categories are derived rather than written down. A skill's own `category`
* field when it declares one, and the pages it attaches to otherwise, so a
* filter can never offer a grouping that matches nothing — and a skill added
* tomorrow appears under its own category without this file being edited.
*/
const ALL = 'all';
/** The groupings the registry actually contains, in a stable order. */
function categoriesFor(skills) {
const named = new Set();
for (const skill of skills) {
if (skill.category) named.add(skill.category);
}
return [ALL, ...[...named].sort()];
}
/** What this skill contributes, in the terms the reader is choosing between. */
function skillSummary(skill) {
const pages = skill.pages.map((p) => surfaceFor(p)?.label || p);
const capabilities = skill.owliver?.capabilities || [];
return [
pages.length ? pages.join(', ') : 'No pages',
capabilities.length
? `${capabilities.length} capabilit${capabilities.length === 1 ? 'y' : 'ies'}`
: (skill.actions || []).length ? `${skill.actions.length} action${skill.actions.length === 1 ? '' : 's'}` : null,
].filter(Boolean).join(' · ');
}
/** @param {any} props */
export function AddSkillsModal({ open, onOpenChange, attached = [], customSkills = [], onAdd }) {
const [query, setQuery] = React.useState('');
const [category, setCategory] = React.useState(ALL);
const [picked, setPicked] = React.useState([]);
/* A fresh sheet each time, so a previous selection is not still ticked. */
React.useEffect(() => {
if (open) { setQuery(''); setCategory(ALL); setPicked([]); }
}, [open]);
/* Owliver skills only. A workforce training path is something a person
learns, not something an agent can be asked to do, and offering one here
would promise behaviour that does not exist. */
const available = React.useMemo(
() => skillsWithFacet(allSkills(customSkills), 'owliver')
.filter((s) => s.status === 'active' && !attached.includes(s.id)),
[customSkills, attached]
);
const categories = React.useMemo(() => categoriesFor(available), [available]);
const visible = React.useMemo(() => {
const q = query.trim().toLowerCase();
return available
.filter((s) => category === ALL || s.category === category)
.filter((s) => !q
|| s.name.toLowerCase().includes(q)
|| s.description.toLowerCase().includes(q)
|| s.id.includes(q));
}, [available, category, query]);
const toggle = (id) =>
setPicked((current) => (current.includes(id)
? current.filter((x) => x !== id)
: [...current, id]));
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Add skills"
description="Select skills to extend what this agent can do. A skill still only answers on the pages it names."
size="lg"
footer={
<>
<p className="mr-auto text-body-sm text-ink-3">
{picked.length
? `${picked.length} skill${picked.length === 1 ? '' : 's'} selected`
: 'Nothing selected yet'}
</p>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button disabled={!picked.length} onClick={() => { onAdd(picked); onOpenChange(false); }}>
Add
</Button>
</>
}
>
<div className="space-y-3">
<SearchInput value={query} onChange={setQuery} placeholder="Search skills..." debounce={0} />
{categories.length > 1 && (
<div className="flex flex-wrap gap-1.5">
{categories.map((id) => (
<button
key={id}
type="button"
onClick={() => setCategory(id)}
className={cn(
`rounded-full border px-2.5 py-1 text-caption font-medium capitalize transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
category === id
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-2'
)}
>
{id === ALL ? 'All skills' : id}
</button>
))}
</div>
)}
<div className="max-h-[22rem] space-y-1.5 overflow-y-auto pr-0.5">
{visible.map((skill) => {
const chosen = picked.includes(skill.id);
return (
<button
key={skill.id}
type="button"
onClick={() => toggle(skill.id)}
aria-pressed={chosen}
className={cn(
`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
chosen
? 'border-krow-blue/50 bg-krow-blue-tint/50'
: 'border-border bg-surface hover:border-krow-blue/30 hover:bg-surface-subtle'
)}
>
<span
className={cn(
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border',
chosen ? 'border-krow-blue bg-krow-blue text-white' : 'border-border bg-surface'
)}
aria-hidden="true"
>
{chosen && <Check className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{skill.name}</span>
{skill.description && (
<span className="mt-0.5 block text-caption leading-snug text-ink-3">
{skill.description}
</span>
)}
<span className="mt-1 block truncate text-[10px] text-ink-4">{skillSummary(skill)}</span>
</span>
</button>
);
})}
{!visible.length && (
<p className="px-1 py-6 text-center text-body-sm text-ink-3">
{available.length
? 'No skills match that.'
: 'Every available skill is already attached to this agent.'}
</p>
)}
</div>
</div>
</Modal>
);
}
export default AddSkillsModal;

View File

@@ -0,0 +1,516 @@
import * as React from 'react';
import { ChevronRight, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { agentIconFor } from './icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
/**
* The Agent Configure workspace, as parts.
*
* The screen this builds is an *authoring* surface, and it is the only one in
* the Admin console that is. The eight operational pages report; this one is
* written in. That is the whole reason it is allowed to look different from
* them, and the difference is composition rather than colour: every token here
* — `border-border`, `surface-subtle`, `krow-blue-tint`, the ink scale, the
* duration and easing variables — is the same Control Tower vocabulary the rest
* of the console uses.
*
* Three ideas carry the layout:
*
* 1. **One document, not a stack of cards.** Four equal cards make four
* unrelated settings groups. One surface divided by hairlines makes a thing
* being edited, which is what an agent definition is.
*
* 2. **A spine on the left.** The rail lists what an agent is made of —
* Identity, Instructions, Capabilities, Behavior, and what is inside each —
* with its current value beside it. It is the tree reading of the
* configuration, and it is what lets someone understand the page before
* reading a single field. It is navigation, never a second copy of state.
*
* 3. **Values on the surface.** A row states what it currently holds while
* closed. Nothing has to be opened to be read.
*
* Motion is CSS only. `prefers-reduced-motion` is handled once, globally, in
* `index.css`, so a component that animates with transitions inherits that and
* a component that animates in JavaScript would not.
*/
/* ── The surface ─────────────────────────────────────────────────────────── */
/**
* The workspace: rail beside document, both inside one border.
*
* The rail only becomes a column when there is genuinely room for one. Owliver
* holds a ~380px track on the right of every Admin page, so below `xl` the
* remaining width belongs to the editor and the rail becomes a strip above it.
*/
export function Workspace({ rail, children, className }) {
return (
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
{rail}
</div>
<div className="min-w-0 w-full divide-y divide-border/60">{children}</div>
</div>
);
}
/* ── Motion ──────────────────────────────────────────────────────────────── */
/**
* Open and close, in height.
*
* `grid-template-rows: 0fr → 1fr` is the one way to transition to an unknown
* height without measuring it in JavaScript. Content stays mounted, which is
* what keeps a section addressable by the rail while it is closed.
*/
/** @param {any} props */
export function Collapse({ open, children, className }) {
return (
<div
className={cn(
'grid transition-[grid-template-rows] duration-slow ease-out',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
className
)}
>
{/* `inert` rather than `aria-hidden`: the content stays mounted so a
closed section is still addressable, and a mounted control that cannot
be seen must not be reachable by Tab either. `aria-hidden` alone would
hide it from a screen reader while leaving it in the tab order, which
is the worse of both. */}
<div
className={cn(
'overflow-hidden transition-opacity duration-base ease-out',
open ? 'opacity-100' : 'opacity-0'
)}
inert={open ? undefined : true}
>
{children}
</div>
</div>
);
}
/* ── The rail ────────────────────────────────────────────────────────────── */
/**
* The spine.
*
* Provides clear section navigation and leaf anchor shortcuts across the
* configuration workspace.
*/
/** @param {any} props */
export function Rail({ sections, activeId, onSelect }) {
const activeSection = sections.find((s) => s.id === activeId);
return (
<nav
aria-label="Agent configuration"
className="flex flex-wrap items-center justify-between gap-2.5"
>
{/* Section anchor pills */}
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{sections.map((section) => {
const active = section.id === activeId;
const Icon = section.icon;
return (
<button
key={section.id}
type="button"
onClick={() => onSelect(section.id)}
aria-current={active ? 'true' : undefined}
className={cn(
`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-body-sm font-medium
transition-all duration-base focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/40`,
active
? 'bg-krow-blue text-white shadow-xs'
: 'border border-border/80 bg-surface text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<Icon
className={cn('h-3.5 w-3.5 shrink-0 transition-colors', active ? 'text-white' : 'text-ink-4')}
aria-hidden="true"
/>
<span>{section.label}</span>
{section.meta && (
<span
className={cn(
'rounded-full px-1.5 py-0.5 text-caption tabular-nums',
active ? 'bg-white/20 text-white' : 'bg-surface-sunken text-ink-4'
)}
>
{section.meta}
</span>
)}
</button>
);
})}
</div>
{/* Sub-item quick jump leaves */}
{activeSection?.items?.length > 0 && (
<div className="hidden items-center gap-1.5 md:flex">
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">Jump to:</span>
{activeSection.items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSelect(activeSection.id, item.id)}
className={`group inline-flex items-center gap-1 rounded-md border border-border/60 bg-surface
px-2 py-1 text-caption font-medium text-ink-3 transition-colors duration-base
hover:border-krow-blue/40 hover:bg-surface-sunken hover:text-ink-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<span className="min-w-0 truncate">{item.label}</span>
{item.meta !== undefined && item.meta !== null && item.meta !== '' && (
<span className="tabular-nums text-ink-4">({item.meta})</span>
)}
</button>
))}
</div>
)}
</nav>
);
}
/* ── The document ────────────────────────────────────────────────────────── */
/**
* One section of the document.
*/
/** @param {any} props */
export function DocSection({
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
children,
}) {
return (
<section
id={id}
aria-labelledby={`${id}-title`}
className={cn('scroll-mt-24 w-full', !last && 'border-b border-border/60')}
>
<button
type="button"
aria-expanded={open}
aria-controls={`${id}-body`}
onClick={onToggle}
className={`group flex w-full items-start gap-3 px-5 py-3.5 text-left transition-colors
duration-base hover:bg-surface-subtle/50 focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/40
sm:px-6`}
>
<span
aria-hidden="true"
className={cn(
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg transition-colors duration-base',
emphasis
? 'bg-krow-blue text-white'
: open
? 'bg-krow-blue-tint text-krow-blue'
: 'bg-surface-sunken text-ink-4 group-hover:text-ink-2'
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
id={`${id}-title`}
className={cn(
'font-heading font-semibold tracking-tight text-ink-1',
emphasis ? 'text-body font-bold' : 'text-body font-semibold'
)}
>
{title}
</span>
{meta !== undefined && meta !== null && meta !== '' && (
<span className="text-caption tabular-nums text-ink-4 font-normal">({meta})</span>
)}
</span>
{description && (
<span className="mt-0.5 block text-caption leading-relaxed text-ink-3">
{description}
</span>
)}
</span>
<ChevronRight
className={cn(
'mt-1 h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
open && 'rotate-90'
)}
aria-hidden="true"
/>
</button>
<Collapse open={open}>
<div id={`${id}-body`} className="px-5 pb-5 pt-1 sm:px-6 w-full">
{children}
</div>
</Collapse>
</section>
);
}
/** A labelled field in the document body. */
/** @param {any} props */
export function DocField({ id, label, hint, children, className }) {
const reactId = React.useId();
const controlId = id ? `${id}-control` : reactId;
return (
<div id={id} className={cn('scroll-mt-28 space-y-1', className)}>
<label
htmlFor={controlId}
className="block text-body-sm font-medium text-ink-2"
>
{label}
</label>
{React.isValidElement(children)
? React.cloneElement(children, { id: children.props.id || controlId })
: children}
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
</div>
);
}
/**
* The head of a group inside a section — Pages, Skills, Knowledge.
*
* One notch quieter than a section title and one louder than a row, which is
* exactly the level of the thing it names.
*/
/** @param {any} props */
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
return (
<div
id={id}
className={cn('flex scroll-mt-28 items-center gap-2.5 pt-3 pb-1.5', className)}
>
{Icon && <Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
<h4 className="text-body-sm font-semibold text-ink-1">{title}</h4>
{count !== undefined && count !== null && (
<span className="rounded-full bg-surface-sunken px-1.5 py-px text-caption font-medium tabular-nums text-ink-3">
{count}
</span>
)}
{action && <div className="ml-auto">{action}</div>}
</div>
);
}
/**
* A thing attached to the agent — a skill, a subagent — with a way to detach it.
*
* A row, not a card. Five attached skills as five cards is five borders
* competing with the section that holds them; as rows they read as a list,
* which is what they are.
*/
/** @param {any} props */
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
return (
<li
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
transition-colors duration-base hover:bg-surface-subtle`}
>
{Icon && (
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{title}</span>
{detail && (
<span className="mt-0.5 block text-caption leading-snug text-ink-3">{detail}</span>
)}
{warning && <span className="mt-0.5 block text-caption text-warning">{warning}</span>}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
aria-label={removeLabel}
className={`shrink-0 rounded-md p-1 text-ink-4 opacity-0 transition-all duration-base
hover:bg-destructive-muted hover:text-destructive focus-visible:opacity-100
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40
group-hover/item:opacity-100`}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</li>
);
}
/**
* A behaviour setting: what it is, what it is set to, and the way to change it.
*
* Two shapes, one row. A setting whose control fits on the row carries it
* there — a switch, a segmented choice — and is not a button, because wrapping
* a switch in a button makes one press mean two things. A setting that needs
* space gets a chevron and opens underneath.
*/
/** @param {any} props */
export function SettingRow({
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
}) {
const expandable = Boolean(children);
const body = (
<>
{Icon && <Icon className="mt-0.5 h-4 w-4 shrink-0 text-ink-4" aria-hidden="true" />}
<span className="min-w-[11rem] flex-1 basis-[14rem]">
<span className="flex flex-wrap items-baseline gap-x-2">
<span className="text-body-sm font-medium text-ink-1">{title}</span>
{value !== undefined && value !== null && value !== '' && (
<span className="text-caption tabular-nums text-ink-3">{value}</span>
)}
</span>
{description && (
<span className="mt-0.5 block text-caption leading-snug text-ink-4">{description}</span>
)}
</span>
</>
);
return (
<div id={id} className={cn('scroll-mt-28', !last && 'border-b border-border/60')}>
{expandable ? (
<button
type="button"
aria-expanded={open}
onClick={onToggle}
className={`-mx-2 flex w-[calc(100%+1rem)] items-start gap-3 rounded-lg px-2 py-2.5 text-left
transition-colors duration-base hover:bg-surface-subtle
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
{body}
<ChevronRight
className={cn(
'mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
open && 'rotate-90'
)}
aria-hidden="true"
/>
</button>
) : (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 py-2.5">
{body}
{control && <div className="ml-auto shrink-0">{control}</div>}
</div>
)}
{expandable && (
<Collapse open={open}>
<div className="pb-3.5 pt-0.5">{children}</div>
</Collapse>
)}
</div>
);
}
/** A page an agent covers, or one it could. */
/** @param {any} props */
export function ScopeChip({ active, children, ...props }) {
return (
<button
type="button"
aria-pressed={active}
className={cn(
`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption font-medium
transition-all duration-base ease-out focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/40`,
active
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-1'
)}
{...props}
>
<span
aria-hidden="true"
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors duration-base',
active ? 'bg-krow-blue' : 'bg-border'
)}
/>
{children}
</button>
);
}
/* ── Identity ────────────────────────────────────────────────────────────── */
/** The icons an agent may wear, as a picker. */
/** @param {any} props */
export function IconPicker({ icons, value, onChange }) {
return (
<div className="grid grid-cols-5 gap-1.5">
{icons.map((icon) => {
const Icon = agentIconFor(icon);
const active = value === icon;
const name = icon.replace(/-/g, ' ');
return (
<button
key={icon}
type="button"
onClick={() => onChange(icon)}
title={name}
aria-label={name}
aria-pressed={active}
className={cn(
`flex h-9 items-center justify-center rounded-lg border transition-all duration-base
ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
active
? 'scale-105 border-krow-blue bg-krow-blue-tint text-krow-blue shadow-xs'
: 'border-border bg-surface text-ink-4 hover:-translate-y-px hover:border-krow-blue/40 hover:text-ink-2'
)}
>
{Icon
? <Icon className="h-4 w-4" />
: <OwliverAvatar className="h-4 w-4" rounded="rounded" />}
</button>
);
})}
</div>
);
}
/**
* How this agent will appear where people actually meet it.
*
* Identity is the one section whose fields have a visible consequence
* elsewhere, so the consequence is shown rather than described. It renders the
* same three things the switcher renders — icon, name, description — and
* nothing it does not.
*/
/** @param {any} props */
export function AgentPreview({ name, description, icon }) {
const Icon = agentIconFor(icon);
return (
<div className="rounded-xl border border-border bg-surface-subtle p-3">
<p className="text-overline uppercase text-ink-4">In Owliver</p>
<div className="mt-2 flex items-start gap-2.5 rounded-lg border border-border bg-surface p-2.5 shadow-xs">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-subtle"
>
{Icon
? <Icon className="h-3.5 w-3.5 text-krow-blue" />
: <OwliverAvatar className="h-4 w-4" rounded="rounded" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{name || 'New agent'}
</span>
<span className="mt-0.5 line-clamp-2 block text-caption leading-snug text-ink-3">
{description || 'No description yet.'}
</span>
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,681 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import {
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
SlidersHorizontal, Trash2, X, Zap,
} from 'lucide-react';
import {
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
SelectValue, Switch, Textarea,
} from '@/components/ds';
import { allSkills } from '@/lib/skills/registry';
import { surfaceFor, SUPPORTED_SKILL_PAGES } from '@/lib/skills/surfaces';
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
import {
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
SettingRow, Workspace,
} from './AgentCanvas';
import { AddSkillsModal } from './AddSkillsModal';
/**
* The agent configuration workspace.
*
* Four sections of one document — Identity, Instructions, Capabilities,
* Behavior — with a rail beside them that lists the same four and what is
* inside each. The rail is the page's table of contents and its scroll
* position; it is never a second source of state, which is why every value it
* shows is read from `fields` at render rather than tracked.
*
* Every control writes a *field*. The caller composes frontmatter from those
* fields at save time, which is what keeps Markdown the storage format while a
* recruiter never meets it.
*/
const SECTION_IDS = ['identity', 'instructions', 'capabilities', 'behavior'];
/**
* Which section the reader is in.
*
* Observed rather than set on click, so scrolling the document moves the rail
* too — a table of contents that only updates when you use it is a menu, not a
* position.
*
* Read from the sections' own top edges against a marker just below the sticky
* header, rather than from intersection ratios. A ratio-based spy cannot
* resolve the case this document actually has: Capabilities is long enough that
* it is still crossing the reading band when Behavior arrives, so "the first
* one intersecting" stays on Capabilities for the rest of the page. The last
* section gets the same exemption for the same reason in reverse — the document
* runs out of scroll before its top reaches the marker, so at the end of the
* page it is simply what is being read.
*/
function useActiveSection(ids) {
const [active, setActive] = React.useState(ids[0]);
React.useEffect(() => {
if (typeof window === 'undefined') return undefined;
let frame = 0;
const measure = () => {
frame = 0;
const marker = 140;
let current = ids[0];
for (const id of ids) {
const top = document.getElementById(id)?.getBoundingClientRect().top;
if (top !== undefined && top <= marker) current = id;
}
/* Only when the document genuinely scrolls. With every section closed it
fits on one screen, and "the end of the page" is then also its start —
which would light the last section while the reader is looking at the
first. */
const doc = document.documentElement;
const scrolls = doc.scrollHeight > window.innerHeight + 8;
const atBottom = window.innerHeight + window.scrollY >= doc.scrollHeight - 4;
if (scrolls && atBottom) current = ids[ids.length - 1];
setActive(current);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
measure();
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
return () => {
if (frame) cancelAnimationFrame(frame);
window.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
};
}, [ids]);
return [active, setActive];
}
/** @param {any} props */
export function AgentConfigure({ fields, agents, customSkills = [], onChange }) {
const [addingSkills, setAddingSkills] = React.useState(false);
/* Sections are open by default: this is a document, and one that greets its
author with four closed headers hides the thing they came to write. Closing
is for focus, not for the initial reading. */
const [open, setOpen] = React.useState(
() => Object.fromEntries(SECTION_IDS.map((id) => [id, true]))
);
const [setting, setSetting] = React.useState(/** @type {string | null} */ (null));
const [active, setActive] = useActiveSection(SECTION_IDS);
const set = (patch) => onChange({ ...fields, ...patch });
const instructionWords = React.useMemo(
() => (fields.instructions.trim() ? fields.instructions.trim().split(/\s+/).length : 0),
[fields.instructions]
);
const registry = React.useMemo(() => allSkills(customSkills), [customSkills]);
const skillById = React.useMemo(() => new Map(registry.map((s) => [s.id, s])), [registry]);
const availableSubagents = React.useMemo(
() => agents.filter((a) => a.id !== fields.id && a.status === 'published'),
[agents, fields.id]
);
const reasoning = REASONING_MODES.find((m) => m.id === fields.reasoning);
const pageLabel = (p) => surfaceFor(p)?.label || p;
const coveredLabels = fields.pages.map(pageLabel);
const starterCount = fields.starters.filter((s) => s.label.trim()).length;
/**
* Going somewhere.
*
* A closed section is still addressable — the rail lists what is inside it,
* and following one of those leaves has to arrive at the field rather than at
* a header that happens to be shut. Opening first, scrolling on the next
* frame, so the target has a height by the time it is scrolled to.
*/
const goTo = React.useCallback((sectionId, itemId) => {
setOpen((current) => (current[sectionId] ? current : { ...current, [sectionId]: true }));
if (itemId && itemId.startsWith('behavior-')) setSetting(itemId);
setActive(sectionId);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const target = document.getElementById(itemId || sectionId)
|| document.getElementById(sectionId);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
}, [setActive]);
const toggleSection = (id) => setOpen((current) => ({ ...current, [id]: !current[id] }));
const toggleSetting = (id) => setSetting((current) => (current === id ? null : id));
/* The rail's model. Derived from the same fields the document renders, so a
count on the rail cannot disagree with the list under it. */
const sections = [
{
id: 'identity',
icon: IdCard,
label: 'Identity',
meta: '',
items: [
{ id: 'identity-name', label: 'Name' },
{ id: 'identity-description', label: 'Description' },
{ id: 'identity-icon', label: 'Icon' },
{ id: 'identity-trigger', label: 'When to use' },
],
},
{
id: 'instructions',
icon: FileText,
label: 'Instructions',
meta: instructionWords ? `${instructionWords}w` : '',
items: [{ id: 'instructions-editor', label: 'Editor' }],
},
{
id: 'capabilities',
icon: Boxes,
label: 'Capabilities',
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
items: [
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
{ id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length },
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
],
},
{
id: 'behavior',
icon: SlidersHorizontal,
label: 'Behavior',
meta: reasoning?.label || '',
items: [
{ id: 'behavior-reasoning', label: 'Reasoning', meta: reasoning?.label },
{ id: 'behavior-starters', label: 'Starters', meta: starterCount },
{ id: 'behavior-web', label: 'Web search', meta: fields.webSearch ? 'On' : 'Off' },
{ id: 'behavior-subagents', label: 'Subagents', meta: fields.subagents.length },
],
},
];
return (
<>
<Workspace rail={<Rail sections={sections} activeId={active} onSelect={goTo} />}>
{/* ── Identity ───────────────────────────────────────────────────── */}
<DocSection
id="identity"
icon={IdCard}
title="Identity"
description="How this agent is presented to people using Owliver."
open={open.identity}
onToggle={() => toggleSection('identity')}
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
<div className="lg:col-span-7 xl:col-span-7 space-y-3">
<DocField id="identity-name" label="Name">
<Input
value={fields.name}
placeholder="e.g. Positions Agent"
onChange={(e) => set({ name: e.target.value })}
className="w-full"
/>
</DocField>
<DocField
id="identity-description"
label="Description"
hint="One line, shown under the name when choosing an agent."
>
<Input
value={fields.description}
placeholder="e.g. Open roles — what they need and which are at risk."
onChange={(e) => set({ description: e.target.value })}
className="w-full"
/>
</DocField>
<DocField
id="identity-trigger"
label="When to use it"
hint="Helps a reader pick the right agent. Never matched against questions."
>
<Textarea
value={fields.trigger}
rows={3}
placeholder="e.g. Use on Positions, for open roles and applicant flow."
onChange={(e) => set({ trigger: e.target.value })}
className="w-full resize-y"
/>
</DocField>
</div>
<div className="lg:col-span-5 xl:col-span-5 space-y-3">
<AgentPreview
name={fields.name}
description={fields.description}
icon={fields.icon}
/>
<div id="identity-icon" className="scroll-mt-28 rounded-xl border border-border bg-surface-subtle/50 p-3">
<p className="mb-1.5 text-body-sm font-medium text-ink-2">Icon</p>
<IconPicker
icons={AGENT_ICONS}
value={fields.icon}
onChange={(icon) => set({ icon })}
/>
</div>
</div>
</div>
</DocSection>
{/* ── Instructions ───────────────────────────────────────────────── */}
<DocSection
id="instructions"
icon={FileText}
title="Instructions"
description="How this agent should answer, what it is responsible for, and what it should say when it cannot help."
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
open={open.instructions}
onToggle={() => toggleSection('instructions')}
emphasis
>
<div
id="instructions-editor"
className={`w-full scroll-mt-28 overflow-hidden rounded-xl border border-border
bg-surface transition-all duration-base ease-out
hover:border-border focus-within:border-krow-blue/50
focus-within:ring-2 focus-within:ring-krow-blue/15`}
>
<Textarea
value={fields.instructions}
rows={8}
placeholder={'Answer from the records this page holds.\n\nState a figure only where a skill has read it. When a reading needs a position and none is open, ask which rather than choosing one.'}
onChange={(e) => set({ instructions: e.target.value })}
className={`min-h-[11rem] resize-y rounded-none border-0 bg-transparent px-4 py-3
text-body-md leading-relaxed focus-visible:ring-0`}
/>
<div className="flex items-center justify-between gap-3 border-t border-border bg-surface-subtle px-4 py-2">
<span className="text-caption tabular-nums text-ink-4">
{instructionWords} word{instructionWords === 1 ? '' : 's'}
</span>
<span className="text-caption text-ink-4">Saved with the agent definition</span>
</div>
</div>
</DocSection>
{/* ── Capabilities ───────────────────────────────────────────────── */}
<DocSection
id="capabilities"
icon={Boxes}
title="Capabilities"
description="What this agent can reach and use when it answers."
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.skills.length} skill${fields.skills.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
open={open.capabilities}
onToggle={() => toggleSection('capabilities')}
>
<div className="divide-y divide-border/60">
{/* Pages ── the boundary. */}
<div className="pb-4">
<GroupHead
id="capabilities-pages"
icon={LayoutGrid}
title="Pages it covers"
count={fields.pages.length}
/>
<p className="pb-2 text-caption leading-relaxed text-ink-3">
{coveredLabels.length
? <>Answers on <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>. Anywhere else it is shown as constrained and declines.</>
: 'No page selected — this agent has nowhere to answer yet.'}
</p>
<div className="flex flex-wrap gap-1.5">
{SUPPORTED_SKILL_PAGES.map((page) => (
<ScopeChip
key={page}
active={fields.pages.includes(page)}
onClick={() => set({
pages: fields.pages.includes(page)
? fields.pages.filter((p) => p !== page)
: [...fields.pages, page],
})}
>
{pageLabel(page)}
</ScopeChip>
))}
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-4">
A page decides which skills exist there. An agent chooses among them — it can
narrow that list, never widen it.
</p>
</div>
{/* Skills ── what it can do. */}
<div className="py-4">
<GroupHead
id="capabilities-skills"
icon={Zap}
title="Skills"
count={fields.skills.length}
action={(
<Button variant="outline" size="sm" onClick={() => setAddingSkills(true)}>
<Plus className="h-3.5 w-3.5" /> Add skills
</Button>
)}
/>
{fields.skills.length > 0 ? (
<ul className="divide-y divide-border">
{fields.skills.map((id) => {
const skill = skillById.get(id);
return (
<ItemRow
key={id}
icon={Zap}
title={skill?.name || id}
detail={skill?.description}
warning={skill ? null : 'No skill with this id is registered.'}
removeLabel={`Remove ${skill?.name || id}`}
onRemove={() => set({ skills: fields.skills.filter((x) => x !== id) })}
/>
);
})}
</ul>
) : (
<p className="text-body-sm text-ink-3">
No skills attached. This agent can still answer from the page&apos;s own reader.
</p>
)}
<p className="mt-2 text-caption text-ink-4">
Authored in{' '}
<Link
to="/admin/workspace/skills"
className="font-medium text-krow-blue underline-offset-2 hover:underline"
>
Workspace &rarr; Skills
</Link>
</p>
</div>
{/* Knowledge ── what it has been told. */}
<div className="pt-4">
<GroupHead
id="capabilities-knowledge"
icon={BookOpen}
title="Knowledge"
count={fields.knowledge.length}
action={(
<Button
variant="outline"
size="sm"
onClick={() => set({
knowledge: [
...fields.knowledge,
{ id: '', label: '', kind: 'note', body: '', url: '' },
],
})}
>
<Plus className="h-3.5 w-3.5" /> Add knowledge
</Button>
)}
/>
{fields.knowledge.length ? (
<div className="space-y-2.5">
{fields.knowledge.map((entry, i) => (
<div
key={entry.id || i}
className="animate-fade-in space-y-2.5 rounded-xl border border-border bg-surface-subtle p-3"
>
<div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
<Field label="Title">
<Input
value={entry.label}
placeholder="e.g. Overtime policy"
onChange={(e) => {
const next = [...fields.knowledge];
next[i] = { ...entry, label: e.target.value };
set({ knowledge: next });
}}
/>
</Field>
<Field label="Kind">
<Select
value={entry.kind}
onValueChange={(kind) => {
const next = [...fields.knowledge];
next[i] = { ...entry, kind };
set({ knowledge: next });
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{KNOWLEDGE_KINDS.map((k) => (
<SelectItem key={k} value={k} className="capitalize">
{k.replace('-', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
{entry.kind === 'link' ? (
<Field label="Link">
<Input
value={entry.url}
placeholder="https://…"
onChange={(e) => {
const next = [...fields.knowledge];
next[i] = { ...entry, url: e.target.value };
set({ knowledge: next });
}}
/>
</Field>
) : (
<Field
label="What it says"
hint="Quoted back with its source named. Nothing is invented from it."
>
<Textarea
value={entry.body}
rows={3}
onChange={(e) => {
const next = [...fields.knowledge];
next[i] = { ...entry, body: e.target.value };
set({ knowledge: next });
}}
/>
</Field>
)}
<Button
variant="ghost"
size="sm"
onClick={() => set({
knowledge: fields.knowledge.filter((_, x) => x !== i),
})}
>
<Trash2 className="h-3.5 w-3.5" /> Remove
</Button>
</div>
))}
</div>
) : (
<p className="text-body-sm text-ink-3">
No knowledge attached. Nothing is invented — this agent answers from skills only.
</p>
)}
</div>
</div>
</DocSection>
{/* ── Behavior ───────────────────────────────────────────────────── */}
<DocSection
id="behavior"
icon={SlidersHorizontal}
title="Behavior"
description="How this agent works when it answers."
meta={reasoning?.label}
open={open.behavior}
onToggle={() => toggleSection('behavior')}
last
>
<div>
<SettingRow
id="behavior-reasoning"
icon={Brain}
title="Reasoning"
description={reasoning?.summary || 'How much work an answer is worth.'}
control={(
<SegmentedToggle
size="sm"
ariaLabel="Reasoning depth"
value={fields.reasoning}
onChange={(id) => set({ reasoning: id })}
options={REASONING_MODES.map((m) => ({ value: m.id, label: m.label }))}
/>
)}
/>
<SettingRow
id="behavior-starters"
icon={MessageSquare}
title="Conversation starters"
value={starterCount ? `${starterCount} configured` : 'None'}
description="Questions offered as chips when this agent opens."
open={setting === 'behavior-starters'}
onToggle={() => toggleSetting('behavior-starters')}
>
<div className="space-y-2">
{fields.starters.map((starter, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={starter.label}
placeholder="e.g. Which roles are at risk?"
className="max-w-[30rem]"
onChange={(e) => {
const next = [...fields.starters];
next[i] = { label: e.target.value, prompt: e.target.value };
set({ starters: next });
}}
/>
<Button
variant="ghost"
size="icon"
aria-label="Remove starter"
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })}
>
<Plus className="h-3.5 w-3.5" /> Add starter
</Button>
</div>
</SettingRow>
<div id="behavior-web" className="scroll-mt-28">
<SettingRow
icon={Globe}
title="Web search"
value={fields.webSearch ? 'On' : 'Off'}
description="Whether this agent may look outside the workspace."
control={(
<Switch
checked={fields.webSearch}
onCheckedChange={(webSearch) => set({ webSearch })}
aria-label="Allow web search"
/>
)}
/>
<Collapse open={fields.webSearch}>
<div className="pb-4">
<Alert tone="warning" title="No search provider is configured">
This deployment has no web search connected, so answers say plainly that
nothing outside the workspace was consulted.
</Alert>
</div>
</Collapse>
</div>
<SettingRow
id="behavior-subagents"
icon={Layers}
title="Subagents"
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
description="Other agents whose skills this one may also use. Most need none."
open={setting === 'behavior-subagents'}
onToggle={() => toggleSetting('behavior-subagents')}
last
>
<div className="space-y-3">
{fields.subagents.length > 0 && (
<ul className="divide-y divide-border">
{fields.subagents.map((id) => {
const sub = agents.find((a) => a.id === id);
return (
<ItemRow
key={id}
icon={Layers}
title={sub?.name || id}
detail={sub?.description}
warning={sub ? null : 'No agent with this id is registered.'}
removeLabel={`Remove ${sub?.name || id}`}
onRemove={() => set({
subagents: fields.subagents.filter((x) => x !== id),
})}
/>
);
})}
</ul>
)}
<Select
value=""
onValueChange={(id) => !fields.subagents.includes(id)
&& set({ subagents: [...fields.subagents, id] })}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder="Add a subagent..." />
</SelectTrigger>
<SelectContent>
{availableSubagents
.filter((a) => !fields.subagents.includes(a.id))
.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-caption leading-relaxed text-ink-4">
A subagent&apos;s skills are still bounded by the current page — borrowing them
cannot reach data this page does not hold.
</p>
</div>
</SettingRow>
</div>
</DocSection>
</Workspace>
<AddSkillsModal
open={addingSkills}
onOpenChange={setAddingSkills}
attached={fields.skills}
customSkills={customSkills}
onAdd={(ids) => set({ skills: [...fields.skills, ...ids] })}
/>
</>
);
}
export default AgentConfigure;

View File

@@ -0,0 +1,238 @@
import * as React from 'react';
import {
Activity,
BarChart3,
Clock,
HelpCircle,
MessageSquare,
ThumbsDown,
ThumbsUp,
Wrench,
} from 'lucide-react';
import { Badge, EmptyState } from '@/components/ds';
import { readHistory } from '@/components/ai-assistant/history';
import { conversationStats, reviewRow } from '@/lib/agents/conversationInsights';
import { allSkills } from '@/lib/skills/registry';
import { usePreferences } from '@/lib/krowHooks';
import { cn } from '@/lib/utils';
/**
* What this agent has actually been asked.
*
* Real-time usage analytics computed directly from the local telemetry log.
*/
/** @param {any} props */
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
return (
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-caption font-medium text-ink-4 uppercase tracking-wider">{label}</span>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-surface-subtle text-ink-3">
<Icon className="h-3.5 w-3.5" />
</span>
</div>
<div className="flex items-baseline gap-2">
<span className="font-heading text-title-xl font-bold tabular-nums text-ink-1 sm:text-3xl">
{value}
</span>
{highlight && (
<span className="text-caption font-semibold text-krow-blue">{highlight}</span>
)}
</div>
{detail && <p className="text-caption text-ink-4">{detail}</p>}
</div>
);
}
/** @param {any} props */
export function AgentInsightsPanel({ agentId, agentName }) {
const preferences = usePreferences();
const [records] = React.useState(() => readHistory());
const stats = React.useMemo(
() => conversationStats(records, { agentId }),
[records, agentId]
);
const skillName = React.useMemo(() => {
const byId = new Map(allSkills(preferences.customSkills || []).map((s) => [s.id, s.name]));
return (id) => byId.get(id) || id;
}, [preferences.customSkills]);
const conversations = React.useMemo(
() => records
.filter((r) => (agentId ? r.agentId === agentId : true))
.slice(0, 8)
.map(reviewRow),
[records, agentId]
);
if (stats.empty) {
return (
<div className="w-full rounded-2xl border border-border bg-surface p-12 shadow-xs text-center">
<EmptyState
icon={MessageSquare}
title="No conversations with this agent yet"
description={`Insights are counted from conversations held on this device. Ask ${agentName || 'this agent'} something in Owliver and the figures appear here.`}
/>
</div>
);
}
return (
<div className="w-full space-y-4">
{/* ── KPI Metric Cards ───────────────────────────────────────────── */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
icon={MessageSquare}
label="Total Conversations"
value={stats.total}
detail={`Active across ${stats.activeDays} day${stats.activeDays === 1 ? '' : 's'}`}
/>
<MetricCard
icon={HelpCircle}
label="Questions Asked"
value={stats.turns}
detail={`${stats.averageTurns} per session average`}
/>
<MetricCard
icon={Activity}
label="Surfaces Utilized"
value={stats.pages}
detail="Operational pages touched"
/>
<MetricCard
icon={ThumbsUp}
label="Feedback Rating"
value={stats.feedback.score === null ? '—' : `${stats.feedback.score}%`}
detail={
stats.feedback.score === null
? 'No ratings submitted'
: `${stats.feedback.up} helpful · ${stats.feedback.down} unhelpful`
}
/>
</div>
{/* ── 2-Column Ranking Breakdowns ────────────────────────────────── */}
<div className="grid gap-4 md:grid-cols-2">
{/* Most Used Skills */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-krow-blue" />
<h4 className="font-heading text-body font-semibold text-ink-1">Most Triggered Skills</h4>
</div>
{stats.bySkill.length ? (
<div className="space-y-2">
{stats.bySkill.slice(0, 5).map((row) => (
<div
key={row.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
>
<span className="truncate font-medium text-ink-2">{skillName(row.id)}</span>
<Badge variant="neutral" size="sm" className="tabular-nums">
{row.count} query{row.count === 1 ? '' : 'ies'}
</Badge>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
No skill executions logged yet. Conversations were handled directly by native page readers.
</div>
)}
</div>
{/* Actions Executed */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2">
<Wrench className="h-4 w-4 text-krow-blue" />
<h4 className="font-heading text-body font-semibold text-ink-1">Actions & Tool Usage</h4>
</div>
{stats.byTool.length ? (
<div className="space-y-2">
{stats.byTool.slice(0, 5).map((row) => (
<div
key={row.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
>
<span className="truncate font-medium text-ink-2">{row.id.replace(/_/g, ' ')}</span>
<Badge variant="neutral" size="sm" className="tabular-nums">
{row.count} run{row.count === 1 ? '' : 's'}
</Badge>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
No action tool executions recorded in current audit window.
</div>
)}
</div>
</div>
{/* ── Recent Conversations Table ─────────────────────────────────── */}
<div className="rounded-2xl border border-border bg-surface shadow-xs overflow-hidden">
<div className="border-b border-border/80 bg-surface-subtle/40 px-5 py-4 sm:px-6">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-krow-blue" />
<h4 className="font-heading text-body font-semibold text-ink-1">
Recent Conversation Logs
</h4>
</div>
</div>
<div className="divide-y divide-border/60">
{conversations.map((row) => (
<div
key={row.id}
className="flex flex-wrap items-center justify-between gap-3 px-5 py-3.5 sm:px-6 hover:bg-surface-subtle/30 transition-colors"
>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="truncate text-body-sm font-medium text-ink-1">
{row.title || 'General consultation'}
</p>
<p className="text-caption text-ink-4">
Surface:{' '}
<span className="font-medium text-ink-3">{row.page || 'Direct'}</span>
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-caption tabular-nums text-ink-4">
{row.turns} turn{row.turns === 1 ? '' : 's'}
</span>
{row.feedback && (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
row.feedback.rating === 'up'
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800'
: 'bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-800'
)}
>
{row.feedback.rating === 'up' ? (
<>
<ThumbsUp className="h-2.5 w-2.5" /> Helpful
</>
) : (
<>
<ThumbsDown className="h-2.5 w-2.5" /> Unhelpful
</>
)}
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
export default AgentInsightsPanel;

View File

@@ -0,0 +1,347 @@
import * as React from 'react';
import {
AlertCircle,
BookOpen,
CheckCircle2,
Cpu,
Layers,
Play,
Search,
Sparkles,
Wrench,
} from 'lucide-react';
import {
Badge,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ds';
import { allSkills, matchSkill, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
import { agentCovers, agentScopedDisabled, classifyQuestion } from '@/lib/agents/runtime';
import { toolsForContext } from '@/lib/skills/tools';
import { retrieveKnowledge } from '@/lib/agents/knowledge';
import { cn } from '@/lib/utils';
/**
* What this agent would do, on a page, before it is published.
*
* Runs the live runtime classifier, skill matcher, tool resolver, and
* knowledge retriever to display comprehensive diagnostics.
*/
const CONTEXT_OPTIONS = Object.entries(PLACEMENT_ROUTES).map(([route, contextId]) => ({
contextId,
route,
label: ASSISTANT_CONTEXTS[contextId]?.page || contextId,
}));
const CLASSIFICATION_COPY = {
structured: 'Reads workforce records through an attached skill.',
knowledge: 'Answers directly from reference knowledge.',
combined: 'Requires both structured records and knowledge retrieval.',
};
/** @param {any} props */
export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
const [contextId, setContextId] = React.useState(
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|| CONTEXT_OPTIONS[0].contextId
);
const [question, setQuestion] = React.useState('What needs my attention?');
/* The draft as the runtime sees it */
const draftAgent = React.useMemo(() => ({
id: fields.id || 'draft',
name: fields.name || 'This agent',
pages: fields.pages,
skills: fields.skills,
subagents: fields.subagents,
knowledge: fields.knowledge,
starters: fields.starters,
reasoning: fields.reasoning,
webSearch: fields.webSearch,
status: 'published',
}), [fields]);
const registry = React.useMemo(() => allSkills(customSkills), [customSkills]);
const result = React.useMemo(() => {
const covers = agentCovers(draftAgent, contextId);
const disabled = agentScopedDisabled(draftAgent, registry, []);
const pageOnly = skillsForContext(contextId, [], customSkills);
const scoped = covers ? skillsForContext(contextId, disabled, customSkills) : [];
const matched = covers ? matchSkill(question, contextId, disabled, customSkills) : null;
return {
covers,
pageOnly,
scoped,
matched,
tools: covers ? toolsForContext(contextId, disabled, customSkills) : [],
knowledge: retrieveKnowledge({ agent: draftAgent, contextId, question }),
classification: classifyQuestion({ question }),
};
}, [draftAgent, contextId, question, registry, customSkills]);
const activePageLabel = ASSISTANT_CONTEXTS[contextId]?.page || 'Current Page';
return (
<div className="w-full space-y-4">
{/* ── Test Workbench Card ────────────────────────────────────────── */}
<div className="w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden">
<div className="border-b border-border/80 bg-surface-subtle/40 px-5 py-4 sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-krow-blue-tint text-krow-blue">
<Sparkles className="h-4 w-4" />
</span>
<div>
<h3 className="font-heading text-body font-semibold text-ink-1">
Simulation & Scope Diagnostics
</h3>
<p className="text-caption text-ink-4">
Evaluate real-time intent routing and skill activation without publishing changes.
</p>
</div>
</div>
{dirty && (
<Badge variant="warning" size="sm" className="gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-warning animate-pulse" />
Testing unsaved draft
</Badge>
)}
</div>
</div>
<div className="p-5 sm:p-6 space-y-4">
<div className="grid gap-4 md:grid-cols-12 items-end">
<div className="md:col-span-4 space-y-1.5">
<label className="block text-body-sm font-medium text-ink-2">
Contextual Page
</label>
<Select value={contextId} onValueChange={setContextId}>
<SelectTrigger className="w-full bg-surface">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTEXT_OPTIONS.map((o) => (
<SelectItem key={o.contextId} value={o.contextId}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="md:col-span-8 space-y-1.5">
<label className="block text-body-sm font-medium text-ink-2">
Simulated User Query
</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-4" />
<Input
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="e.g. Which roles are at risk?"
className="pl-9 w-full bg-surface"
/>
</div>
</div>
</div>
{fields.starters.filter((s) => s.label).length > 0 && (
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50">
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">
Suggested Starters:
</span>
{fields.starters.filter((s) => s.label).map((s, i) => (
<button
key={i}
type="button"
onClick={() => setQuestion(s.prompt || s.label)}
className={cn(
`inline-flex items-center gap-1.5 rounded-lg border border-border/80 bg-surface px-2.5 py-1
text-caption font-medium text-ink-2 transition-all duration-base
hover:border-krow-blue/40 hover:bg-krow-blue-tint/30 hover:text-krow-blue
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
question === (s.prompt || s.label) && 'border-krow-blue bg-krow-blue-tint text-krow-blue'
)}
>
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
<span>{s.label}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* ── Active Status Banner ───────────────────────────────────────── */}
<div
className={cn(
'flex items-start gap-3.5 rounded-2xl border p-4 sm:p-5 transition-colors',
result.covers
? 'border-emerald-200 bg-emerald-50/70 text-emerald-950 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50/70 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100'
)}
>
<span
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full mt-0.5',
result.covers
? 'bg-emerald-600 text-white dark:bg-emerald-500'
: 'bg-amber-600 text-white dark:bg-amber-500'
)}
>
{result.covers ? <CheckCircle2 className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
</span>
<div className="min-w-0 flex-1">
<h4 className="font-heading text-body font-semibold">
{result.covers ? `Active on ${activePageLabel}` : `Constrained on ${activePageLabel}`}
</h4>
<p className="mt-0.5 text-body-sm leading-relaxed opacity-90">
{result.covers
? result.scoped.length
? `${result.scoped.length} of ${result.pageOnly.length} operational skills are active and ready on this surface.`
: `This page provides ${result.pageOnly.length} skills. With none attached, this agent will answer using the page's standard reader.`
: `This agent has not been assigned to ${activePageLabel}. When asked on this page, it will politely decline or defer to the workspace reader.`}
</p>
</div>
</div>
{/* ── 4-Quadrant Diagnostic Grid ─────────────────────────────────── */}
<div className="grid gap-4 md:grid-cols-2">
{/* Quadrant 1: Intent & Skill Routing */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2 text-ink-1">
<Cpu className="h-4 w-4 text-krow-blue shrink-0" />
<h4 className="font-heading text-body font-semibold">Intent & Skill Routing</h4>
</div>
<p className="text-caption text-ink-3">
{CLASSIFICATION_COPY[result.classification] || CLASSIFICATION_COPY.structured}
</p>
{result.matched ? (
<div className="rounded-xl border border-krow-blue/40 bg-krow-blue-tint/40 p-3 space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-body-sm font-semibold text-krow-blue">
{result.matched.name}
</span>
<Badge variant="info" size="sm">Matched</Badge>
</div>
<p className="text-caption text-ink-3 leading-relaxed">
{result.matched.description}
</p>
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
No specialized skill claimed this exact wording. The native page reader will process the query.
</div>
)}
</div>
{/* Quadrant 2: Reachable Skills */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center justify-between gap-2 text-ink-1">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-krow-blue shrink-0" />
<h4 className="font-heading text-body font-semibold">Reachable Page Skills</h4>
</div>
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
{result.scoped.length}
</span>
</div>
{result.scoped.length ? (
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
{result.scoped.map((s) => (
<div
key={s.id}
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
>
<span className="truncate font-medium text-ink-2">{s.name}</span>
<span className="text-caption text-ink-4 tabular-nums">Ready</span>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
No attached skills apply to {activePageLabel}.
</div>
)}
</div>
{/* Quadrant 3: Execution Capabilities */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center justify-between gap-2 text-ink-1">
<div className="flex items-center gap-2">
<Wrench className="h-4 w-4 text-krow-blue shrink-0" />
<h4 className="font-heading text-body font-semibold">Execution Actions & Tools</h4>
</div>
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
{result.tools.length}
</span>
</div>
{result.tools.length ? (
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
{result.tools.map((tool) => (
<div
key={tool.name}
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
>
<span className="truncate font-medium text-ink-2">{tool.label}</span>
{tool.requiresApproval ? (
<Badge variant="warning" size="sm">Confirmation required</Badge>
) : (
<Badge variant="neutral" size="sm">Read-only</Badge>
)}
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
Read-only operations. No active mutations or system write actions.
</div>
)}
</div>
{/* Quadrant 4: Referenced Knowledge */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2 text-ink-1">
<BookOpen className="h-4 w-4 text-krow-blue shrink-0" />
<h4 className="font-heading text-body font-semibold">Referenced Knowledge</h4>
</div>
{result.knowledge.passages.length ? (
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
{result.knowledge.passages.map((p) => (
<div
key={p.chunkId}
className="rounded-lg border border-border/70 bg-surface-subtle/50 p-2.5 text-body-sm"
>
<span className="block font-medium text-ink-1">{p.label}</span>
<span className="mt-0.5 line-clamp-2 block text-caption text-ink-3 leading-relaxed">
{p.text}
</span>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
{result.knowledge.note || 'No custom reference knowledge attached to this agent.'}
</div>
)}
</div>
</div>
</div>
);
}
export default AgentTestPanel;

View File

@@ -0,0 +1,30 @@
import {
Activity, BarChart3, Briefcase, GraduationCap, Layers, Shield, Sparkles, UserCheck, Users,
} from 'lucide-react';
/**
* Agent icon id → component.
*
* A hand-written table, for the same reason `SECTION_COMPONENTS` is one: a
* definition names a key, and only a key written down here resolves to
* anything. There is no path from a Markdown file to a component nobody chose.
*
* `owliver` is deliberately absent — it is not a lucide glyph but the product's
* own avatar, and the switcher draws it directly. Returning `null` for it is
* the signal to do that, which keeps the primary agent looking like Owliver
* rather than like a generic icon.
*/
const ICONS = {
sparkles: Sparkles,
briefcase: Briefcase,
users: Users,
'user-check': UserCheck,
layers: Layers,
'graduation-cap': GraduationCap,
'bar-chart': BarChart3,
activity: Activity,
shield: Shield,
};
/** The component for an icon id, or null when the avatar should be drawn. */
export const agentIconFor = (id) => ICONS[String(id || '').trim()] ?? null;

View File

@@ -0,0 +1,196 @@
import * as React from 'react';
import { usePreferences } from '@/lib/krowHooks';
import { allAgents } from '@/lib/agents/registry';
import {
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
} from '@/lib/agents/runtime';
/**
* Which agent is answering.
*
* Built on the same principles as `PageContext.jsx`, and deliberately kept
* beside it rather than merged into it:
*
* - **The page is not an input to itself.** This context reads the page's
* assistant context to decide a *default*, and never the other way round.
* Nothing here can change which page the reader is on, which is what makes
* "switching agent cannot change PageContext" true by construction rather
* than by care.
* - **Session-scoped, like the panel's own window state.** Choosing an agent
* for the afternoon should not rewrite an account default, and a new tab
* should open on the page's own agent.
*
* Resolution order: what this session chose *and that still applies here* → the
* account's stated default, on the same condition → the page's own agent → the
* general agent. A page therefore always opens on the agent written for it, a
* page nobody wrote one for opens on the general agent rather than on nothing,
* and a deliberate choice still survives navigation across the pages it covers.
*
* **A selection is made somewhere.** That is the correction this file carries:
* it stores the context a choice was made on, not only the agent's id. Storing
* the id alone meant a specialist chosen on Positions followed the reader onto
* Settings and constrained a page nobody had chosen it for — the panel looked
* broken and the reason was invisible. The rule itself lives in
* `runtime.resolveSelection` as a pure function, so what applies where is
* decided in one place and can be proved without a React tree.
*/
const AgentContext = React.createContext(null);
const SESSION_KEY = 'krow_assistant:agent';
/**
* The stored selection: `{ id, contextId }`.
*
* A bare string is still read, because that is what earlier sessions wrote and
* a stored value from yesterday should not throw. It resolves as a choice made
* nowhere, which is the honest reading of it — and the conservative one, since a
* selection with no context applies only where it legitimately covers.
*/
function readSelection() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null;
if (!raw.startsWith('{')) return { id: raw, contextId: null };
const parsed = JSON.parse(raw);
return parsed?.id ? { id: parsed.id, contextId: parsed.contextId ?? null } : null;
} catch {
/* Private mode, or a value that will not parse: the session simply always
opens on the page's own agent. */
return null;
}
}
function writeSelection(selection) {
try {
if (selection?.id) sessionStorage.setItem(SESSION_KEY, JSON.stringify(selection));
else sessionStorage.removeItem(SESSION_KEY);
} catch {
/* Held in memory for this session only. */
}
}
export function AgentProvider({ contextId = null, children }) {
const preferences = usePreferences();
/* Shipped definitions plus anything this account has authored, read through
the one registry so the switcher and the management page cannot disagree
about what exists. */
const agents = React.useMemo(
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
[preferences.customAgents, preferences.customSkills]
);
const [selection, setSelection] = React.useState(() => readSelection());
const select = React.useCallback((id) => {
/* Stamped with where it was chosen. A specialist picked on a page it does
not cover is a deliberate act and is honoured *here*; the stamp is what
stops it from becoming a decision about every other page too. */
const next = id ? { id, contextId } : null;
setSelection(next);
writeSelection(next);
}, [contextId]);
/** Back to whichever agent this page resolves on its own. */
const clearSelection = React.useCallback(() => {
setSelection(null);
writeSelection(null);
}, []);
/* What the stored choice means on the page the reader is on now. */
const applied = React.useMemo(
() => resolveSelection(agents, selection, contextId),
[agents, selection, contextId]
);
/**
* Storage housekeeping, in one place.
*
* Retiring a spent selection and re-stamping a live one are both writes about
* *navigation*, not about a choice, so they belong in an effect rather than in
* `select`. Both converge: the effect only writes when the stored value would
* actually change.
*/
React.useEffect(() => {
if (applied.retire) {
setSelection(null);
writeSelection(null);
return;
}
if (applied.id && selection?.contextId !== contextId) {
const next = { id: applied.id, contextId };
setSelection(next);
writeSelection(next);
}
}, [applied, selection, contextId]);
const value = React.useMemo(() => {
/**
* What this turn asks for.
*
* The session's choice first, then the account default — and the account
* default is put through the same rule, as a choice made nowhere. A stated
* default that does not cover this page is not a decision about this page,
* so it must not constrain it; the page resolves its own agent instead.
*/
const stated = applied.id
|| resolveSelection(agents, preferences.defaultAgentId || null, contextId).id
|| null;
const turn = resolveAgentForTurn(agents, stated, contextId);
/* The page's own agent, offered as the way out of a constrained state.
Null on a page nobody wrote one for — `fallback` is what answers there. */
const native = nativeAgentForContext(agents, contextId);
/* What this page opens on with nothing chosen: its own agent, or the
general one. Never null, so no page is ever left without an agent. */
const fallback = resolveDefaultAgent(agents, contextId);
return {
agents,
/* The agent that will answer. Never silently swapped: a reader who chose
one *for this page* gets that one, with `covers` saying whether it
belongs here. A choice carried in from another page is not that. */
agent: turn.agent,
covers: turn.covers,
native,
/* What this page resolves to with nothing chosen — its own agent, or the
general one. This is what a constrained answer points at, so that the
way out of a constrained state is named on every page rather than only
on the pages that have a specialist. */
defaultAgent: fallback,
/* True while this page is simply showing the agent it resolves on its
own, whether that is its specialist or the general agent. */
isNative: Boolean(fallback && turn.agent && fallback.id === turn.agent.id && !applied.id),
chosenId: applied.id,
select,
clearSelection,
/* Whether a given agent belongs on this page, for the list. */
coversPage: (candidate) => agentCovers(candidate, contextId),
};
}, [agents, applied, preferences.defaultAgentId, contextId, select, clearSelection]);
return <AgentContext.Provider value={value}>{children}</AgentContext.Provider>;
}
/**
* The active agent.
*
* Returns an inert value outside a provider, so a panel rendered in isolation —
* a test, a storybook — behaves as it did before agents existed rather than
* crashing.
*/
export function useActiveAgent() {
return React.useContext(AgentContext) ?? {
agents: [],
agent: null,
covers: true,
native: null,
defaultAgent: null,
isNative: false,
chosenId: null,
select: () => {},
clearSelection: () => {},
coversPage: () => false,
};
}

View File

@@ -0,0 +1,234 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown, Plus, Search, SquareArrowOutUpRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { searchAgents } from '@/lib/agents/registry';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useActiveAgent } from './AgentContext';
/**
* The agent switcher, inside Owliver's own header.
*
* Designed with high-density Krow Control Tower aesthetics: clean popover
* geometry, smooth row hover/active states, and legible status indicators.
*/
/** The avatar for the primary agent; a lucide glyph for the rest. */
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
const Icon = agentIconFor(agent?.icon);
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
className
)}
aria-hidden="true"
>
<Icon className="h-4 w-4" />
</span>
);
}
/** The label a constrained agent carries, in the switcher and in the header. */
function ConstrainedTag({ className = '' }) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300',
className
)}
>
Constrained
</span>
);
}
/** One row in the list. */
function AgentRow({ agent, active, covers, onSelect }) {
return (
<button
type="button"
role="option"
aria-selected={active}
onClick={() => onSelect(agent.id)}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active
? 'bg-krow-blue-tint/70 text-ink-1'
: 'hover:bg-surface-subtle text-ink-2'
)}
>
<AgentGlyph agent={agent} className="h-7 w-7 shrink-0" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="min-w-0 truncate text-body-sm font-semibold text-ink-1">
{agent.name}
</span>
{!covers && <ConstrainedTag />}
</span>
{agent.description && (
<span className="mt-0.5 block truncate text-[11px] leading-tight text-ink-4">
{agent.description}
</span>
)}
</span>
{active && (
<Check className="h-4 w-4 text-krow-blue shrink-0 ml-1" aria-hidden="true" />
)}
</button>
);
}
export function AgentSwitcher({ page }) {
const navigate = useNavigate();
const { agents, agent, covers, select, coversPage } = useActiveAgent();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const listRef = React.useRef(null);
React.useEffect(() => {
if (!open) setQuery('');
}, [open]);
const matches = React.useMemo(() => {
const found = searchAgents(agents, query);
return [...found].sort((a, b) => Number(coversPage(b)) - Number(coversPage(a)));
}, [agents, query, coversPage]);
const choose = (id) => {
select(id);
setOpen(false);
};
const onListKeyDown = (event) => {
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
const rows = [...(listRef.current?.querySelectorAll('[role="option"]') ?? [])];
if (!rows.length) return;
event.preventDefault();
const at = rows.indexOf(document.activeElement);
rows[Math.max(0, Math.min(rows.length - 1, (at === -1 ? 0 : at + step)))]?.focus();
};
if (!agent) {
return (
<>
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
<div className="min-w-0 flex-1">
<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">{page}</p>
</div>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`Owliver agent: ${agent.name}. Switch to another agent`}
aria-haspopup="listbox"
aria-expanded={open}
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<AgentGlyph agent={agent} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1">
<span className="min-w-0 truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
Owliver
</span>
<ChevronDown
className="h-3 w-3 shrink-0 text-ink-4 transition-colors group-hover:text-krow-blue"
aria-hidden="true"
/>
</span>
<span className="flex items-center gap-1.5">
<span className="truncate text-caption leading-tight text-ink-3">{page}</span>
{!covers && <ConstrainedTag />}
</span>
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-[21.5rem] p-0 shadow-lg rounded-xl border border-border/80 overflow-hidden">
<div className="border-b border-border/60 bg-surface-subtle/40 px-3.5 py-2.5">
<p className="font-heading text-body-sm font-semibold text-ink-1">Switch agent</p>
<p className="mt-0.5 text-[11px] text-ink-4">
Choose an agent to focus Owliver's scope on this page.
</p>
</div>
<div className="px-3 pt-2.5 pb-1">
<label className="relative block">
<span className="sr-only">Search agents</span>
<Search
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
aria-hidden="true"
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search agents..."
className="w-full h-8 rounded-lg border border-border/70 bg-surface-subtle/40 py-1 pl-8 pr-2.5 text-body-sm text-ink-1 placeholder:text-ink-4 transition-all focus:bg-surface focus:border-krow-blue/50 focus:outline-none focus:ring-1 focus:ring-krow-blue/30"
/>
</label>
</div>
<div
ref={listRef}
role="listbox"
aria-label="Agents"
onKeyDown={onListKeyDown}
className="max-h-[16rem] space-y-0.5 overflow-y-auto px-2 py-1.5"
>
{matches.map((candidate) => (
<AgentRow
key={candidate.id}
agent={candidate}
active={candidate.id === agent.id}
covers={coversPage(candidate)}
onSelect={choose}
/>
))}
{!matches.length && (
<p className="px-3 py-4 text-center text-body-sm text-ink-3">No agents match that.</p>
)}
</div>
<div className="flex items-center justify-between border-t border-border/80 bg-surface-subtle/30 px-3 py-2">
<button
type="button"
onClick={() => { setOpen(false); navigate('/admin/workspace/agents'); }}
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-caption font-medium text-ink-3 transition-colors hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<SquareArrowOutUpRight className="h-3 w-3" aria-hidden="true" />
Browse all
</button>
<button
type="button"
onClick={() => { setOpen(false); navigate('/admin/workspace/agents/new'); }}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-caption font-semibold text-krow-blue transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
Create agent
</button>
</div>
</PopoverContent>
</Popover>
);
}
export default AgentSwitcher;

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
@@ -35,9 +36,58 @@ export function ThinkingIndicator() {
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them.
*/
/**
* How this conversation was rated.
*
* Offered on the latest answer only, because a rating belongs to the
* conversation rather than to a paragraph of it — thumbs on every turn would
* ask a reader to score an answer out of the exchange that produced it, and
* each one would set the same value anyway.
*
* Hidden until the turn is hovered or focused, the way History's delete control
* already is: a thread of reports should not carry two permanent buttons under
* every answer.
*/
function FeedbackControls({ feedback, onFeedback }) {
const rate = (rating) => onFeedback(feedback?.rating === rating ? null : rating);
return (
<div
className={cn(
'mt-2 flex items-center gap-0.5 transition-opacity',
/* Stays visible once rated, so a reader can see and change their own
answer rather than having to hunt for it again. */
feedback ? 'opacity-100' : 'opacity-0 group-hover/turn:opacity-100 group-focus-within/turn:opacity-100'
)}
>
{[
{ rating: 'up', Icon: ThumbsUp, label: 'This answer was useful' },
{ rating: 'down', Icon: ThumbsDown, label: 'This answer was not useful' },
].map(({ rating, Icon, label }) => (
<button
key={rating}
type="button"
onClick={() => rate(rating)}
aria-label={label}
aria-pressed={feedback?.rating === rating}
className={cn(
`rounded p-1 transition-colors focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
feedback?.rating === rating
? 'text-krow-blue'
: 'text-ink-4 hover:text-ink-2'
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</button>
))}
</div>
);
}
export const Message = React.memo(
/** @param {any} props */
({ role, text, blocks, streaming, stopped, onPrompt }) => {
({ role, text, blocks, streaming, stopped, onPrompt, feedback = null, onFeedback = null }) => {
if (role === 'user') {
return (
<div className="flex justify-end">
@@ -49,7 +99,7 @@ export const Message = React.memo(
}
return (
<div className="animate-slide-up">
<div className="group/turn animate-slide-up">
<div className="mb-2 flex items-center gap-1.5">
<OwliverAvatar className="h-5 w-5" rounded="rounded-full" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
@@ -58,6 +108,12 @@ export const Message = React.memo(
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
{/* Only where a handler was passed — a turn nobody is collecting feedback
for renders exactly as it did before this existed. */}
{onFeedback && !streaming && (
<FeedbackControls feedback={feedback} onFeedback={onFeedback} />
)}
</div>
);
});

View File

@@ -2,6 +2,7 @@ import * as React from 'react';
import { base44 } from '@/api/base44Client';
import { resolveAssistantContext } from './placement';
import { PageContextProvider } from './PageContext';
import { AgentProvider } from './AgentContext';
/**
* Window state for the Owliver panel: open/collapsed and default/expanded.
@@ -195,9 +196,16 @@ export function AssistantPanelProvider({ role, pathname, children }) {
/* The page's own selection travels beside the window state, mounted here so
both the page and the panel are inside it — a skill's data source resolves
against the record the reader has open, whichever of the two is asking. */
/* The page's own selection travels beside the window state, and the active
agent beside both. `AgentProvider` is given the resolved context id and can
only read it — nothing inside it can change which page the reader is on,
which is what makes "switching agent never moves PageContext" structural
rather than a rule to remember. */
return (
<AssistantPanelContext.Provider value={value}>
<PageContextProvider>{children}</PageContextProvider>
<PageContextProvider>
<AgentProvider contextId={context?.id ?? null}>{children}</AgentProvider>
</PageContextProvider>
</AssistantPanelContext.Provider>
);
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
} from 'lucide-react';
@@ -9,12 +9,12 @@ import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import {
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
useMarkInterviewReady, useUpdateJobPosting,
useMarkInterviewReady, useShiftRecords, useUpdateJobPosting,
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { runAction } from '@/lib/skills/actions';
import { skillsForContext } from '@/lib/skills/registry';
import { allSkills, skillsForContext } from '@/lib/skills/registry';
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
@@ -23,7 +23,10 @@ import { useAssistantPanel } from './AssistantPanelContext';
import { usePageContext } from './PageContext';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
import { buildOwliverContext } from '@/lib/agents/context';
import { AgentSwitcher } from './AgentSwitcher';
import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
@@ -215,6 +218,7 @@ export default function KrowAssistant({
const facts = useAssistantFacts();
const userName = useCurrentUserName();
const navigate = useNavigate();
const location = useLocation();
/* A question a page has handed over — see the effect below `runPrompt`. */
const { request: panelRequest, consumeRequest } = useAssistantPanel();
@@ -230,10 +234,36 @@ export default function KrowAssistant({
the app already has rather than from anything written into this component.
A page whose skills change needs no edit here. */
const preferences = usePreferences();
const disabledSkills = React.useMemo(
/* `defaultAgent` rather than `native`: on a page with no agent of its own the
way out of a constrained state is the general agent, and naming nothing
there would leave the reader with a decline and no next step. */
const { agent, covers: agentCoversPage, defaultAgent } = useActiveAgent();
/**
* What this page will not offer.
*
* The account's own switched-off skills, plus everything the active agent
* does not carry. **This one memo is the entire agent scoping**: every
* consumer below — the skill list, the suggestions, the conversation, the
* tools — already takes `disabledSkills`, so none of them needed changing.
*
* It can only ever subtract. `getSkillsForPage` applies the page filter and
* this list in the same expression, so an agent cannot make a skill appear on
* a page that does not carry it, and with no agent the list is exactly the
* account's own.
*/
const accountDisabled = React.useMemo(
() => preferences.disabledSkills || [],
[preferences.disabledSkills]
);
const allRegisteredSkills = React.useMemo(
() => allSkills(preferences.customSkills || []),
[preferences.customSkills]
);
const disabledSkills = React.useMemo(
() => agentScopedDisabled(agent, allRegisteredSkills, accountDisabled),
[agent, allRegisteredSkills, accountDisabled]
);
const { data: customCategories = [] } = useRoleCategories();
const roles = React.useMemo(
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
@@ -270,6 +300,17 @@ export default function KrowAssistant({
[statesFor, facts.forge, facts.profiles, facts.user]
);
const { data: assignments = [] } = useAssignments();
/* Shifts worked, missed and overrun — the records behind attendance and
overtime. Read from the same cache a page would, so a card and an answer
about the same fortnight cannot disagree. */
const { data: shifts = [] } = useShiftRecords();
/* Where the reader is, composed from the page's own channel — see
`lib/agents/context.js`. Read-only: nothing here writes back to the page. */
const owliverContext = React.useMemo(
() => buildOwliverContext({ context, pathname: location.pathname, pageContext }),
[context, location.pathname, pageContext]
);
const skillContext = React.useMemo(() => ({
...pageContext,
applications: facts.applications || [],
@@ -284,8 +325,9 @@ export default function KrowAssistant({
assignments,
staff: facts.staff || [],
activity: facts.activity || [],
shifts,
trainingPaths,
}), [pageContext, facts, assignments, trainingPaths]);
}), [pageContext, facts, assignments, shifts, trainingPaths]);
/**
* Which face of the panel the body is showing.
@@ -407,6 +449,7 @@ export default function KrowAssistant({
const {
messages, pending, error, busy, send, stop, reset,
history, conversationId, openConversation, forgetConversation,
submitFeedback, feedback,
} = useConversation({
contextId: context.id,
facts,
@@ -425,6 +468,10 @@ export default function KrowAssistant({
skillCategories,
courses: facts.forge?.library || [],
skillContext,
agent,
agentCoversPage,
agentSuggestion: defaultAgent,
owliverContext,
});
/* A block inside an answer asking the next question, in place. Same entry
@@ -518,7 +565,12 @@ export default function KrowAssistant({
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
};
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
/* The agent's own starters lead: they were written for this agent, and they
pass through the same de-duplication below, so a starter worded like a
skill suggestion still yields one chip rather than two. An agent that
does not cover this page offers none. */
return [...agentStarters(agent, context.id), ...ready, ...skillPrompts,
...buildPrompts(context.id, facts, workforce), ...asking]
.filter((chip) => {
const label = String(chip?.label ?? '').trim().toLowerCase();
if (!label) return false;
@@ -530,7 +582,13 @@ export default function KrowAssistant({
});
/* `pageContext` decides which suggestions can answer without asking, so the
chips re-rank when a position is opened or closed. */
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext]);
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
/* The newest assistant turn, which is the one that carries the rating. */
const lastAnswerIndex = React.useMemo(
() => messages.map((m) => m.role).lastIndexOf('assistant'),
[messages]
);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
@@ -614,12 +672,11 @@ export default function KrowAssistant({
>
{/* Header */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
<div className="min-w-0 flex-1">
<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>
{/* The avatar and the two lines beside it, as they always were —
"Owliver" over the page, with the pair doubling as the agent
switcher. The header element, its geometry and the window controls
to the right are unchanged. */}
<AgentSwitcher page={context.page} />
{/* Window controls.
@@ -732,6 +789,14 @@ export default function KrowAssistant({
blocks={message.blocks}
stopped={message.stopped}
onPrompt={askOwliver}
/* A rating belongs to the conversation, so it is offered on
the newest answer only — and never while one streams. */
feedback={message.role === 'assistant' && i === lastAnswerIndex ? feedback : null}
onFeedback={
message.role === 'assistant' && i === lastAnswerIndex && !pending
? submitFeedback
: null
}
/>
</React.Fragment>
))}

View File

@@ -0,0 +1,552 @@
import { doc, heading, list, note, text } from '../blocks';
/* `vocabulary.js` is a leaf — it imports nothing — so naming the reasoning
modes here costs no dependency.
The registries are deliberately **not** imported. `contexts.js` is reached
from `placement.js`, which `skills/registry.js` already depends on, so
importing a registry back into a context closes a cycle and leaves
`PLACEMENT_ROUTES` undefined at module-evaluation time. Counting agents here
would also duplicate what the screen behind this panel already shows. */
import { REASONING_MODES } from '@/lib/agents/vocabulary';
/**
* Owliver on the agent configuration screen.
*
* This page is a **workspace** page, not an operational one. It shows no
* positions, no candidates and no shifts, and Owliver must not behave as though
* it does. What it can genuinely answer from is the two registries — what
* agents exist, what skills exist, what the configuration options mean — which
* is metadata about the workspace itself rather than a reading of workforce
* records.
*
* The distinction matters most when the agent being *edited* is an operational
* one. Configuring the Analytics Agent does not put the reader on Analytics:
* the edited agent is a record being changed, not the page they are standing
* on. So nothing here reaches for analytics data, and no skill declares this
* page — `skillsForContext` returns an empty list, which is the honest answer.
*
* Questions outside these topics are declined by the routing layer rather than
* answered with whatever this page happens to know.
*/
/** What this page's answers are actually about. */
/**
* What this page's answers are actually about.
*
* Deliberately narrow, and it was not narrow enough at first: `what is` and
* `page` were on this list, so "What is our attendance rate?" matched and was
* answered from a screen that holds no attendance records. A topic list on a
* page with no operational data has to name *subjects*, never sentence
* openings — a generic phrase turns the honest decline into a confident answer
* about the wrong thing.
*
* Anything not named here is declined by `routing.js` and pointed at the page
* that holds the records.
*/
export const AGENT_CONFIGURE_TOPICS = [
'agent', 'agents', 'subagent', 'subagents',
'skill', 'skills', 'knowledge',
'reasoning', 'starter', 'starters', 'web search',
'instruction', 'instructions', 'configure', 'configuration', 'setting', 'settings',
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
'workspace',
];
/** What this screen is, and what it can be asked. */
function explainScreen() {
return doc(
heading('Configuring an agent'),
text('An agent decides how Owliver answers on a page: which of that page\u2019s skills it may use, what it knows, and how much work an answer is worth.'),
list([
'It narrows what a page offers. It can never widen it.',
'The same skill can be attached to several agents.',
'Changes are saved to this workspace; publishing puts them into service.',
]),
note('The agents in this workspace are listed on the Agents page, and skills in Workspace \u2192 Skills.')
);
}
/** Where skills come from. */
function skillOverview() {
return doc(
heading('Where skills come from'),
text('Skills are authored once in **Workspace \u2192 Skills** and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.'),
list([
'Add skills \u2014 attaches from the shared library.',
'A skill still only answers on the pages it itself declares.',
'Attaching a skill an agent\u2019s pages do not cover changes nothing on those pages.',
]),
note('Removing a skill here detaches it from this agent. It stays in the library for everything else.')
);
}
/** What the configuration options mean. */
function explainOptions() {
return doc(
heading('What each setting does'),
list([
'Pages it covers — where this agent may answer. It narrows what the page offers; it can never widen it.',
'Skills — what it can do. Attached from the shared library.',
'Knowledge — reference material it can quote, with the source named.',
`Reasoning — how much work an answer is worth: ${REASONING_MODES.map((m) => m.label).join(', ')}.`,
'Conversation starters — the questions offered as chips when this agent opens.',
'Web search — whether it may look outside the workspace.',
'Subagents — other agents whose skills it may also use, still bounded by the current page.',
]),
note('Changes are saved to this workspace. Publishing is what puts them into service.')
);
}
/** The lifecycle, in the words the buttons use. */
function explainLifecycle() {
return doc(
heading('Draft, published, archived'),
list([
'Draft — saved, but not answering anywhere yet.',
'Published — in service, and offered in the Owliver switcher.',
'Archived — taken out of service, definition kept. It can be restored as a draft.',
]),
note('Editing an agent that ships with Krow saves your own version alongside it. The shipped definition is never altered, and reverting brings it back.')
);
}
export const AGENT_CONFIGURE_CAPABILITIES = [
{ id: 'screen', label: 'What this screen configures', run: explainScreen },
{ id: 'skills', label: 'Where skills come from', run: skillOverview },
{ id: 'options', label: 'What each setting does', run: explainOptions },
{ id: 'lifecycle', label: 'Draft, published, archived', run: explainLifecycle },
];
/**
* A free-text question on the configuration screen.
*
* Answers from the registries, or says plainly that this page cannot answer it.
* The one thing it must never do is reach for operational data because the
* agent being edited happens to be an operational agent.
*/
export function respondAgentConfigure(question) {
const q = String(question || '').toLowerCase();
if (/\bskill/.test(q)) return skillOverview();
if (/\b(draft|publish|archive|version|revert|shipped)/.test(q)) return explainLifecycle();
if (/\b(reasoning|knowledge|starter|subagent|web search|instruction|setting|option|page)/.test(q)) {
return explainOptions();
}
if (/\bagent/.test(q)) return explainScreen();
return doc(
text('This is the agent configuration screen, so I can answer about **agents, skills and what each setting does** here.'),
list([
'What agents exist in this workspace',
'What skills I can attach, and where they come from',
'What pages, reasoning, knowledge and subagents mean',
'What draft, published and archived do',
]),
note('For workforce questions — positions, candidates, attendance — open the page that holds those records and ask me there.')
);
}
/* ── The rest of the workspace, and Settings ──────────────────────────────
*
* Every page below is a configuration surface: it holds no positions, no
* candidates and no shifts. That does not make Owliver useless there, and
* treating it as though it did is the bug this section exists to correct — the
* panel did not mount on these pages at all, on a product where Owliver had
* answered perfectly well everywhere before specialised agents existed.
*
* What each page can honestly answer from is the screen itself: what it
* configures, what the controls mean, and which page holds the records a
* workforce question is really about. That is what these produce.
*
* **No skill is invented to make this work.** A skill is a capability over
* records; none of these pages has records, so a skill here would be a fake one
* whose only purpose was to populate a chip. What answers instead is a page
* responder — the same mechanism every context in this table has always used,
* including the eight operational ones.
*/
/**
* One configuration page's answers, from a description of the page.
*
* A factory rather than five near-identical copies. Each section carries the
* questions it answers (`match`), the label the panel offers it under, and the
* document itself — so the capability list shown when a question is declined
* and the routing behind a typed question come from one declaration and cannot
* drift apart.
*
* **Sections are matched in order, specific before general.** The section that
* explains the screen as a whole is deliberately last on every page: it is the
* one whose words appear in every other question, and put first it would answer
* all of them. The same lesson `AGENT_CONFIGURE_TOPICS` records, one level down.
*
* `topics` is what `routing.js` gates on, and it names *subjects* rather than
* sentence openings — a generic opener on a page with no operational data turns
* an honest decline into a confident answer about the wrong thing.
*/
function workspacePage({ label, topics, sections, elsewhere }) {
const capabilities = sections.map((section) => ({
id: section.id,
label: section.label,
run: () => doc(
heading(section.heading),
...(section.text ? [text(section.text)] : []),
...(section.bullets?.length ? [list(section.bullets)] : []),
...(section.note ? [note(section.note)] : [])
),
}));
const byId = new Map(capabilities.map((c) => [c.id, c]));
/**
* A typed question, answered from this page or offered what it can answer.
*
* The default is not "here is the page's report" — these pages have no report.
* It is the offer itself: what can be asked here, and where the records live
* for what cannot. `routing.js` has already declined anything outside
* `topics`, so this only ever sees a question the page is plausibly about.
*/
const respond = (question) => {
const q = String(question || '').toLowerCase();
const matched = sections.find((section) => section.match.test(q));
if (matched) return byId.get(matched.id).run();
return doc(
text(`This is **${label}**, so I can answer about the screen itself — what it configures and what each control does.`),
list(sections.map((section) => section.label)),
note(elsewhere)
);
};
return { topics, capabilities, respond };
}
/** What every configuration page says about where the records are. */
const RECORDS_ELSEWHERE =
'For workforce questions — positions, candidates, attendance, training — open the page that holds those records and ask me there.';
/* ── Settings ───────────────────────────────────────────────────────────── */
const SETTINGS = workspacePage({
label: 'Settings',
topics: [
'setting', 'settings', 'account', 'password', 'credential', 'security', 'two-factor',
'two factor', '2fa', 'permission', 'access', 'organization', 'organisation',
'client', 'user', 'users', 'team', 'audit', 'notification', 'digest', 'email',
'density', 'compact', 'preference', 'automation', 'toggle', 'configure', 'configuration',
'workspace', 'owliver', 'assistant',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'automation',
label: 'What the automation toggles do',
heading: 'Automation controls',
match: /\b(automation|toggle|toggles|digest|notification|notifications|density|compact|assistant)\b/,
bullets: [
'Owliver Workspace Assistant — whether this panel opens by default on the pages that carry it.',
'Compact UI Density — tighter table rows, for high-density monitoring.',
'Daily Executive Email Digest — a scheduled summary to the account address.',
],
note: 'Turning the assistant off closes the panel; it never removes it. The docked control brings it back.',
},
{
id: 'access',
label: 'Permissions and security',
heading: 'Who may do what',
match: /\b(permission|permissions|access|secure|secured|security|password|credential|credentials|audit|2fa|two.factor|user|users|team)\b/,
text: 'Access is configured here and recorded on Activity. The two are deliberately separate: this page states the policy, and the audit trail states what happened under it.',
bullets: [
'Users & Permissions — the roles this workspace grants.',
'Account & Security — credentials and protection for your own account.',
'Audit & System Activity Log — the record of events, read in full on Activity.',
],
note: 'Ask me on Activity for who did what, and when — that page holds the events.',
},
{
id: 'screen',
label: 'What this screen configures',
heading: 'Settings',
match: /\b(configure|configuration|configures|screen|settings|manage|set up|do here|organization|organisation|client|clients)\b/,
text: 'Settings is the account and system side of Krow. It configures who you are and how the workspace behaves — never the workforce records themselves.',
bullets: [
'Account & Security — your profile details and how this account is protected.',
'Organization — the clients, positions and people this workspace is structured around.',
'Users & Permissions — who may do what.',
'Audit & System Activity — the log of what has happened.',
'Automation — the behaviours below, including whether Owliver opens by default.',
],
note: 'Changes here are saved to this account.',
},
],
});
export const SETTINGS_TOPICS = SETTINGS.topics;
export const SETTINGS_CAPABILITIES = SETTINGS.capabilities;
export const respondSettings = SETTINGS.respond;
/* ── Workspace hub ──────────────────────────────────────────────────────── */
const WORKSPACE = workspacePage({
label: 'Workspace',
topics: [
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
'configuration', 'govern', 'development',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'agents',
label: 'What an agent is',
heading: 'Agents',
match: /\b(agent|agents|subagent|subagents|reasoning)\b/,
text: 'An agent decides how Owliver answers on a page: which of that page’s skills it may use, what it knows, and how much work an answer is worth.',
bullets: [
'It narrows what a page offers. It can never widen it.',
'A page written for an agent opens on that agent.',
'A page with no agent of its own opens on the general Krow Workforce Agent.',
],
note: 'Agents are listed and edited in Workspace → Agents.',
},
{
id: 'skills',
label: 'Where skills come from',
heading: 'Skills',
match: /\b(skill|skills|library|registry|capability|capabilities)\b/,
text: 'Skills are authored once and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.',
bullets: [
'An Owliver skill answers questions.',
'A Board skill extends a Krow page with a section.',
'A skill only applies on the pages it itself declares.',
],
note: 'Both lists live in Workspace → Skills.',
},
{
id: 'screen',
label: 'What the workspace governs',
heading: 'Workspace',
match: /\b(workspace|govern|governs|overview|configure|configuration|extend)\b/,
text: 'The workspace is where Krow’s capabilities are configured: the agents that answer, the skills they carry, and the training paths the workforce is measured against.',
bullets: [
'Agents — who answers on which page, and how.',
'Skills — what can be answered or drawn, authored once and shared.',
'Skill Development — the training paths the workforce progresses along.',
],
note: 'Nothing configured here reads workforce records on its own. A page decides what is in reach; configuration decides how much of that reach is used.',
},
],
});
export const WORKSPACE_TOPICS = WORKSPACE.topics;
export const WORKSPACE_CAPABILITIES = WORKSPACE.capabilities;
export const respondWorkspace = WORKSPACE.respond;
/* ── Agents list ────────────────────────────────────────────────────────── */
const WORKSPACE_AGENTS = workspacePage({
label: 'Agents',
topics: [
'agent', 'agents', 'subagent', 'subagents', 'skill', 'skills', 'page', 'pages',
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
'configure', 'configuration', 'reasoning', 'workspace', 'owliver',
'constrained', 'cover', 'covers', 'fallback', 'default',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'coverage',
label: 'How a page picks its agent',
heading: 'Which agent answers where',
match: /\b(cover|covers|covered|page|pages|default|fallback|constrained|picks|pick|answers|resolve|resolves)\b/,
bullets: [
'A page with an agent written for it opens on that agent.',
'A page with none opens on the general Krow Workforce Agent.',
'Choosing an agent that does not cover the page you are on shows it as constrained here, and it declines rather than answering.',
],
note: 'An agent is a lens on the page you are standing on. It is never a way to reach a different page.',
},
{
id: 'lifecycle',
label: 'Draft, published, archived',
heading: 'Draft, published, archived',
match: /\b(draft|publish|published|publishing|archive|archived|revert|shipped|version|lifecycle)\b/,
bullets: [
'Draft — saved, but not answering anywhere yet.',
'Published — in service, and offered in the Owliver switcher.',
'Archived — taken out of service, definition kept. It can be restored as a draft.',
],
},
{
id: 'list',
label: 'What this list holds',
heading: 'The agents in this workspace',
match: /\b(list|lists|registered|exist|exists|agent|agents|screen|workspace)\b/,
text: 'Every agent Krow ships, plus anything this account has authored. Opening one configures it; nothing on this screen changes how Owliver is answering right now.',
bullets: [
'A shipped agent can be edited — your version is saved alongside it, and reverting brings the original back.',
'A published agent is offered in the Owliver switcher.',
'An archived agent is out of service, and its definition is kept.',
],
note: 'Configuring an agent never puts you on the page that agent covers.',
},
],
});
export const WORKSPACE_AGENTS_TOPICS = WORKSPACE_AGENTS.topics;
export const WORKSPACE_AGENTS_CAPABILITIES = WORKSPACE_AGENTS.capabilities;
export const respondWorkspaceAgents = WORKSPACE_AGENTS.respond;
/* ── Skills list ────────────────────────────────────────────────────────── */
const WORKSPACE_SKILLS = workspacePage({
label: 'Skills',
topics: [
'skill', 'skills', 'library', 'registry', 'owliver', 'board', 'section', 'surface',
'placement', 'markdown', 'definition', 'upload', 'author', 'attach',
'active', 'draft', 'archive', 'archived', 'category', 'capability', 'capabilities',
'workspace', 'agent', 'agents', 'page', 'pages',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'attaching',
label: 'How skills reach an agent',
heading: 'Attaching a skill',
match: /\b(attach|attached|attaching|agent|agents|reach|reaches|scope)\b/,
bullets: [
'Attaching a skill lets an agent use it — on the pages the skill itself declares.',
'Attaching a skill an agent’s pages do not cover changes nothing on those pages.',
'Removing a skill from an agent leaves it in the library for everything else.',
],
note: 'Agents are configured in Workspace → Agents.',
},
{
id: 'authoring',
label: 'How a skill is written',
heading: 'Writing a skill',
match: /\b(author|authored|authoring|write|written|writing|markdown|definition|upload|edit|edited)\b/,
text: 'A skill is a Markdown file: frontmatter declares what it is and which pages it applies to, and the body documents what it can do.',
bullets: [
'It only applies on the pages it declares.',
'It is attached to an agent from this shared library, never copied into one.',
'A definition naming something the product does not have is refused with a message rather than half-registered.',
],
},
{
id: 'lists',
label: 'The two skill lists',
heading: 'Owliver skills and Board skills',
match: /\b(list|lists|two|owliver|board|kind|kinds|difference|library|registry|skill|skills)\b/,
bullets: [
'An Owliver skill answers questions in this panel.',
'A Board skill extends a Krow page with a section the page renders.',
'A definition can do both; it is filed by what it declares, never by a setting.',
],
note: 'One library behind both lists, so a skill behaves identically wherever it is used.',
},
],
});
export const WORKSPACE_SKILLS_TOPICS = WORKSPACE_SKILLS.topics;
export const WORKSPACE_SKILLS_CAPABILITIES = WORKSPACE_SKILLS.capabilities;
export const respondWorkspaceSkills = WORKSPACE_SKILLS.respond;
/* ── Skill editor ───────────────────────────────────────────────────────── */
const SKILL_CONFIGURE = workspacePage({
label: 'Skill Configure',
topics: [
'skill', 'skills', 'definition', 'markdown', 'frontmatter', 'field', 'fields',
'page', 'pages', 'surface', 'placement', 'section', 'source', 'sources', 'trigger',
'triggers', 'action', 'actions', 'capability', 'capabilities', 'category', 'status',
'active', 'draft', 'archive', 'archived', 'validate', 'validation', 'save', 'publish',
'editor', 'configure', 'configuration', 'owliver', 'board', 'workspace',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'validation',
label: 'Why a definition is refused',
heading: 'Validation',
match: /\b(valid|validate|validated|validation|refuse|refused|error|errors|fail|fails|reject|rejected|wrong)\b/,
text: 'The vocabulary is closed, so everything a definition names is checked before it is stored.',
bullets: [
'A page, placement or source the product does not have is refused with a message.',
'A section that needs a record the placement does not supply is refused rather than drawing an empty card.',
'A definition that parses but loses a field reports what it lost, instead of half-registering.',
],
},
{
id: 'reach',
label: 'Where this skill will apply',
heading: 'Where it applies',
match: /\b(apply|applies|applied|where|reach|reaches|attach|attached|agent|agents|scope|surface|placement)\b/,
bullets: [
'On the pages this definition declares, and nowhere else.',
'For an agent, only once it is attached to that agent.',
'An agent can never use a skill to reach a page the skill does not declare.',
],
note: 'Attach it in Workspace → Agents.',
},
{
id: 'screen',
label: 'What this editor configures',
heading: 'Editing a skill',
match: /\b(editor|edit|editing|configure|configures|configuration|definition|frontmatter|field|fields|status|trigger|triggers|screen|skill|skills)\b/,
text: 'A skill definition, as Markdown. The frontmatter declares what it is and where it applies; the body documents what it can do.',
bullets: [
'Pages — the surfaces this skill applies on. It never applies anywhere else.',
'Triggers — the wordings that reach it.',
'Capabilities — what it can answer or draw.',
'Status — active, draft or archived.',
],
note: 'Nothing is executed from Markdown. A definition names things the product already has, and a name it does not have is refused.',
},
],
});
export const SKILL_CONFIGURE_TOPICS = SKILL_CONFIGURE.topics;
export const SKILL_CONFIGURE_CAPABILITIES = SKILL_CONFIGURE.capabilities;
export const respondSkillConfigure = SKILL_CONFIGURE.respond;
/* ── Skill Development ──────────────────────────────────────────────────── */
const SKILL_DEVELOPMENT = workspacePage({
label: 'Skill Development',
topics: [
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
'development', 'capability', 'capabilities', 'define', 'definition',
'workspace', 'configure', 'configuration', 'owliver',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'levels',
label: 'How the levels work',
heading: 'Levels',
match: /\b(level|levels|rung|rungs|ladder|progress|progression|stage|stages)\b/,
bullets: [
'Levels are ordered, and each states what someone at that level can do.',
'A path with no levels defines a name and nothing measurable.',
'Editing a level changes the definition, never anyone’s recorded progress.',
],
},
{
id: 'screen',
label: 'What a training path is',
heading: 'Skill Development',
match: /\b(training|path|paths|define|defines|definition|development|capability|capabilities|configure|screen)\b/,
text: 'A training path defines a capability the workforce holds and the levels it progresses through. It is a definition, not a reading: nothing here says who currently holds what.',
bullets: [
'Each path names its levels, from first exposure to independent practice.',
'A path is neither an Owliver skill nor a Board skill — it describes people, not the product.',
'It is authored here and read wherever progression is shown.',
],
note: 'For who holds which capability today, ask me on Talent Pool or KROW Forge — those pages hold the records.',
},
],
});
export const SKILL_DEVELOPMENT_TOPICS = SKILL_DEVELOPMENT.topics;
export const SKILL_DEVELOPMENT_CAPABILITIES = SKILL_DEVELOPMENT.capabilities;
export const respondSkillDevelopment = SKILL_DEVELOPMENT.respond;

View File

@@ -13,6 +13,17 @@ import {
respondPositions,
respondProfile, respondTalentPool,
} from './capabilities/admin';
import {
AGENT_CONFIGURE_CAPABILITIES, AGENT_CONFIGURE_TOPICS,
SETTINGS_CAPABILITIES, SETTINGS_TOPICS,
SKILL_CONFIGURE_CAPABILITIES, SKILL_CONFIGURE_TOPICS,
SKILL_DEVELOPMENT_CAPABILITIES, SKILL_DEVELOPMENT_TOPICS,
WORKSPACE_AGENTS_CAPABILITIES, WORKSPACE_AGENTS_TOPICS,
WORKSPACE_CAPABILITIES, WORKSPACE_SKILLS_CAPABILITIES, WORKSPACE_SKILLS_TOPICS,
WORKSPACE_TOPICS,
respondAgentConfigure, respondSettings, respondSkillConfigure, respondSkillDevelopment,
respondWorkspace, respondWorkspaceAgents, respondWorkspaceSkills,
} from './capabilities/workspace';
/**
* Page contexts for the Owliver dashboard panel.
@@ -145,6 +156,87 @@ export const ASSISTANT_CONTEXTS = {
capabilities: PROFILE_CAPABILITIES,
respond: respondProfile,
},
/**
* Agent configuration — a workspace page, not an operational one.
*
* It holds no workforce records, and no skill declares it, so
* `skillsForContext` returns nothing here. That is deliberate rather than an
* omission: configuring the Analytics Agent must not put the reader on
* Analytics, and the surest way to guarantee that is for this page to have no
* operational reading available at all.
*
* `topics` keeps it honest in the other direction — a workforce question asked
* here is declined and pointed at the page that holds the records, rather than
* answered from whatever this screen happens to know.
*/
'admin.agentConfigure': {
id: 'admin.agentConfigure',
page: 'Agent Configure',
topics: AGENT_CONFIGURE_TOPICS,
capabilities: AGENT_CONFIGURE_CAPABILITIES,
respond: respondAgentConfigure,
},
/**
* Settings, and the workspace pages behind it.
*
* **A page with no agent of its own is not a page without Owliver.** These
* six carry the same panel as the eight operational pages, resolved by the
* same placement table, answered by the same responder mechanism — and they
* open on the general Krow Workforce Agent, because no specialist was written
* for a configuration screen and none needs to be.
*
* What they do not carry is workforce records. No skill declares these
* surfaces, so `skillsForContext` returns an empty list on every one of them,
* and `topics` keeps the decline honest in the other direction: a question
* about positions or attendance asked here is pointed at the page that holds
* those records rather than answered from a configuration screen.
*
* The two halves are the whole design. Remove the first and Owliver is dead
* on half the product; remove the second and it invents figures on a page
* that has none.
*/
'admin.settings': {
id: 'admin.settings',
page: 'Settings',
topics: SETTINGS_TOPICS,
capabilities: SETTINGS_CAPABILITIES,
respond: respondSettings,
},
'admin.workspace': {
id: 'admin.workspace',
page: 'Workspace',
topics: WORKSPACE_TOPICS,
capabilities: WORKSPACE_CAPABILITIES,
respond: respondWorkspace,
},
'admin.workspaceAgents': {
id: 'admin.workspaceAgents',
page: 'Agents',
topics: WORKSPACE_AGENTS_TOPICS,
capabilities: WORKSPACE_AGENTS_CAPABILITIES,
respond: respondWorkspaceAgents,
},
'admin.workspaceSkills': {
id: 'admin.workspaceSkills',
page: 'Skills',
topics: WORKSPACE_SKILLS_TOPICS,
capabilities: WORKSPACE_SKILLS_CAPABILITIES,
respond: respondWorkspaceSkills,
},
'admin.skillConfigure': {
id: 'admin.skillConfigure',
page: 'Skill Configure',
topics: SKILL_CONFIGURE_TOPICS,
capabilities: SKILL_CONFIGURE_CAPABILITIES,
respond: respondSkillConfigure,
},
'admin.skillDevelopment': {
id: 'admin.skillDevelopment',
page: 'Skill Development',
topics: SKILL_DEVELOPMENT_TOPICS,
capabilities: SKILL_DEVELOPMENT_CAPABILITIES,
respond: respondSkillDevelopment,
},
'admin.activity': {
id: 'admin.activity',
page: 'Activity',

View File

@@ -205,6 +205,24 @@ const GREETINGS = {
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week, nothing out of pattern.`;
},
'admin.agentConfigure': () =>
'Configure what this agent covers, its attached skills, reference knowledge, and reasoning behavior.',
/* Settings and the workspace pages. Static rather than computed, because a
configuration screen has no records to read and a line that pretended
otherwise would be the fabrication the whole design refuses. */
'admin.settings': () =>
'Your account, the organization, who may do what, and the behaviours this workspace runs on.',
'admin.workspace': () =>
'The agents that answer, the skills they carry, and the paths the workforce progresses along.',
'admin.workspaceAgents': () =>
'Every agent this workspace has, what each one covers, and which are in service.',
'admin.workspaceSkills': () =>
'The shared library — what Owliver can answer, and what a Krow page can draw.',
'admin.skillConfigure': () =>
'Declare what this skill is, which pages it applies on, and what it can do.',
'admin.skillDevelopment': () =>
'Define the capabilities the workforce holds, and the levels they progress through.',
};
/**
@@ -233,6 +251,13 @@ const TITLES = {
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.profile': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.agentConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.settings': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.workspace': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.workspaceAgents': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.workspaceSkills': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.skillConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.skillDevelopment': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
@@ -262,6 +287,20 @@ const DESCRIPTIONS = {
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
'admin.profile': () =>
'Reading this account — permissions, security, preferences and your own activity.',
'admin.agentConfigure': () =>
'Ask Owliver about agent settings, lifecycle, attaching skills, or custom instructions.',
'admin.settings': () =>
'Ask Owliver what this screen configures, what each control does, and where the records behind a workforce question live.',
'admin.workspace': () =>
'Ask Owliver what the workspace governs — agents, skills, and how a page decides which agent answers.',
'admin.workspaceAgents': () =>
'Ask Owliver how a page picks its agent, what constrains one, and what draft, published and archived mean.',
'admin.workspaceSkills': () =>
'Ask Owliver about the two skill lists, how a skill is written, and how one reaches an agent.',
'admin.skillConfigure': () =>
'Ask Owliver what this editor declares, why a definition is refused, and where the skill will apply.',
'admin.skillDevelopment': () =>
'Ask Owliver what a training path defines and how its levels work.',
};
/**
@@ -342,6 +381,41 @@ const PLACEHOLDERS = {
'Ask Owliver what you can change here…',
'Ask Owliver about your security settings…',
],
'admin.agentConfigure': () => [
'Ask Owliver about agent configuration…',
'Ask Owliver how skills attach…',
'Ask Owliver about reasoning modes…',
],
'admin.settings': () => [
'Ask Owliver what you can configure here…',
'Ask Owliver what the automation toggles do…',
'Ask Owliver about permissions and security…',
],
'admin.workspace': () => [
'Ask Owliver what the workspace governs…',
'Ask Owliver what an agent is…',
'Ask Owliver where skills come from…',
],
'admin.workspaceAgents': () => [
'Ask Owliver how a page picks its agent…',
'Ask Owliver what constrained means…',
'Ask Owliver about draft and published…',
],
'admin.workspaceSkills': () => [
'Ask Owliver about the two skill lists…',
'Ask Owliver how a skill is written…',
'Ask Owliver how skills reach an agent…',
],
'admin.skillConfigure': () => [
'Ask Owliver what this editor declares…',
'Ask Owliver why a definition is refused…',
'Ask Owliver where this skill will apply…',
],
'admin.skillDevelopment': () => [
'Ask Owliver what a training path is…',
'Ask Owliver how the levels work…',
'Ask Owliver what this screen defines…',
],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
@@ -671,6 +745,52 @@ const PROMPTS = {
{ label: 'Who is most active?', prompt: 'Which accounts are most active?', capability: 'user-activity' },
],
'admin.agentConfigure': () => [
{ label: 'Where do skills come from?', prompt: 'Where do skills come from on this screen?' },
{ label: 'Explain reasoning modes', prompt: 'What are the reasoning modes and how do they work?' },
{ label: 'How to write instructions?', prompt: 'How should I write effective instructions for this agent?' },
{ label: 'How does publishing work?', prompt: 'How does agent publishing and versioning work?' },
],
/**
* Settings and the workspace pages.
*
* Every chip below resolves to something this page can actually answer, and
* that is the whole rule: a suggestion is a promise, so a configuration screen
* must not offer "Which positions are at risk?" merely to have chips. None of
* these names a record; each one names the screen. The operational chips above
* are computed from data because those pages have data — these are not, for
* exactly the same reason.
*/
'admin.settings': () => [
{ label: 'What can I configure here?', prompt: 'What can I configure on Settings?' },
{ label: 'What do the automation toggles do?', prompt: 'What do the automation toggles do?' },
{ label: 'Who can access what?', prompt: 'Who can access what, and where is it recorded?' },
],
'admin.workspace': () => [
{ label: 'What does the workspace govern?', prompt: 'What does the workspace govern?' },
{ label: 'What is an agent?', prompt: 'What is an agent, and what does it decide?' },
{ label: 'Where do skills come from?', prompt: 'Where do skills come from?' },
],
'admin.workspaceAgents': () => [
{ label: 'How does a page pick its agent?', prompt: 'How does a page pick its agent?' },
{ label: 'What does draft mean?', prompt: 'What do draft, published and archived mean?' },
{ label: 'What is in this list?', prompt: 'What does this list of agents hold?' },
],
'admin.workspaceSkills': () => [
{ label: 'How do skills reach an agent?', prompt: 'How do skills reach an agent?' },
{ label: 'How is a skill written?', prompt: 'How is a skill written?' },
{ label: 'What are the two lists?', prompt: 'What is the difference between the two skill lists?' },
],
'admin.skillConfigure': () => [
{ label: 'Why is a definition refused?', prompt: 'Why would a definition be refused?' },
{ label: 'Where will this apply?', prompt: 'Where will this skill apply?' },
{ label: 'What does this editor declare?', prompt: 'What does this editor configure?' },
],
'admin.skillDevelopment': () => [
{ label: 'How do the levels work?', prompt: 'How do the levels work?' },
{ label: 'What is a training path?', prompt: 'What is a training path?' },
],
};
/* ── Public API ─────────────────────────────────────────────────────────── */

View File

@@ -31,6 +31,42 @@ const MAX_RECORDS = 100;
const DAY = 24 * 60 * 60 * 1000;
/**
* The shape of a stored record.
*
* Bumped when a record gains fields, so a reader can tell an old record from a
* new one instead of guessing from which keys happen to be present.
*/
const SCHEMA = 2;
/**
* An older record, brought up to date on the way out.
*
* A conversation held before agents existed is still a conversation somebody
* had. It gets the new fields at their empty values and keeps everything it
* already had — in particular `messages` is passed through untouched, so
* nothing a reader wrote is rewritten by a schema change.
*
* Migrating on read rather than rewriting the store means a browser that never
* opens History again still loses nothing, and there is no migration pass that
* can fail halfway.
*/
function migrate(record) {
if (record.schema === SCHEMA) return record;
return {
...record,
schema: SCHEMA,
/* Absent, not unknown: these conversations genuinely had no agent, no
recorded skills and no feedback. */
agentId: record.agentId ?? null,
pageContext: record.pageContext ?? null,
skillsUsed: Array.isArray(record.skillsUsed) ? record.skillsUsed : [],
toolsUsed: Array.isArray(record.toolsUsed) ? record.toolsUsed : [],
knowledgeUsed: Array.isArray(record.knowledgeUsed) ? record.knowledgeUsed : [],
feedback: record.feedback ?? null,
};
}
/** Every archived conversation, newest first. Never throws. */
export function readHistory() {
try {
@@ -42,6 +78,7 @@ export function readHistory() {
return list
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
.map(migrate)
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
} catch {
/* Corrupt or unavailable storage is an empty history, not an error the
@@ -64,13 +101,39 @@ function titleFor(messages) {
* Matched on `id`, so a thread being added to updates in place rather than
* appearing once per turn.
*/
export function saveConversation({ id, contextId, page, messages }) {
export function saveConversation({
id, contextId, page, messages,
/**
* Who answered, where, and what it actually used.
*
* Recorded from what ran rather than from what was available: `skillsUsed`
* is the skill that answered a turn, not every skill the page offered. A
* record of what *could* have happened would make the insight figures
* describe the registry instead of the conversation.
*/
agentId = null, pageContext = null,
skillsUsed = [], toolsUsed = [], knowledgeUsed = [],
}) {
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
/* Feedback belongs to the conversation, not to the turn that triggered a
save, so an existing rating survives the thread growing. */
const existing = readHistory().find((r) => r.id === id) || null;
const record = {
schema: SCHEMA,
id,
contextId,
page: page || '',
agentId,
/* The *reduced* envelope — where the question was asked, never a copy of
what was on screen. Storing selections and computed figures would write
the dataset into localStorage a turn at a time. */
pageContext,
skillsUsed: [...new Set(skillsUsed.filter(Boolean))],
toolsUsed: [...new Set(toolsUsed.filter(Boolean))],
knowledgeUsed: [...new Set(knowledgeUsed.filter(Boolean))],
feedback: existing?.feedback ?? null,
title: titleFor(messages),
turns: messages.filter((m) => m.role === 'user').length,
updatedAt: new Date().toISOString(),
@@ -95,6 +158,41 @@ export function saveConversation({ id, contextId, page, messages }) {
return next;
}
/**
* Records how a conversation was rated.
*
* Updates in place and never appends: rating a thread twice is a correction,
* not two opinions. Returns the refreshed list so a caller re-renders from one
* read rather than two.
*/
export function recordFeedback(id, feedback) {
const list = readHistory();
const at = list.findIndex((r) => r.id === id);
if (at === -1) return list;
const next = [...list];
next[at] = {
...next[at],
feedback: feedback
? {
rating: feedback.rating === 'up' ? 'up' : 'down',
note: String(feedback.note || '').trim() || null,
at: new Date().toISOString(),
}
/* Clearing is a real action — someone un-rating a thread should leave no
rating behind rather than a neutral one. */
: null,
};
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* The rating stays in memory for this session. */
}
return next;
}
/** Forgets one conversation. */
export function removeConversation(id) {
const next = readHistory().filter((r) => r.id !== id);

View File

@@ -11,6 +11,7 @@
import { isUnlocked } from '@/lib/provingGround';
import { getScoreBand } from '@/lib/talentHome';
import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
@@ -26,10 +27,12 @@ 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.
* hired for. Defined in `lib/activitySignals.js` alongside the detection that
* uses it, and re-exported here because the fact sheet, the Activity page's
* severity column and the assistant's security answers all import it from this
* module and must keep agreeing on what "privileged" means.
*/
export const PRIVILEGED_EVENTS = ['hire_candidate', 'create_position'];
export { PRIVILEGED_EVENTS };
/** `today` is injected rather than read from the clock so answers are stable. */
export function buildFacts({
@@ -119,64 +122,15 @@ export function buildFacts({
/**
* 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.
* Computed by `lib/activitySignals.js` rather than here, because it now has
* a second reader: the `activity.signals` data source resolves it for a card
* or an answer, and `dataResolver.js` cannot import from this directory
* without a library reaching up into the component tree.
*
* 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.
* Moved, not changed. "Two unusual patterns" has to mean the same two
* everywhere it is said, and one function is the only way to guarantee that.
*/
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,
};
})();
const signals = activitySignals(activity, today);
/* ── KROW Forge ──────────────────────────────────────────────────────────
Derived with the same gate (`isUnlocked`) and the same completion record the
@@ -386,7 +340,7 @@ export function buildFacts({
activity7d: since(7),
activity24h: since(1),
eventCounts,
activitySignals,
activitySignals: signals,
/* Signed-in worker, and the pages built on these records */
profile,

View File

@@ -28,6 +28,40 @@ const PLACEMENT = {
'/admin/talent-pool': 'admin.talentPool',
'/admin/hired': 'admin.hiredHistory',
'/admin/profile': 'admin.profile',
/* Agent management. Configuring an agent is a workspace task, not an
operational page — see `admin.agentConfigure` for what that means for
what Owliver may read here. `agents/new` is listed exactly so it is
never read as an agent whose id is "new". */
'/admin/workspace/agents/new': 'admin.agentConfigure',
/**
* Settings and the rest of the workspace.
*
* These pages have no agent written for them, and for a while that was read
* as "no Owliver here" — the panel did not mount at all, so a product that
* answered questions on every Admin page before specialists existed
* answered on eight of them afterwards.
*
* The absence of a specialist is not the absence of an assistant. Each of
* these carries the same panel, resolved by this same table, answering from
* the general agent. What they do *not* carry is operational data: no skill
* declares these surfaces, so a workforce question asked here is declined
* and pointed at the page that holds the records rather than answered from
* a configuration screen.
*/
'/admin/settings': 'admin.settings',
'/admin/workspace': 'admin.workspace',
'/admin/workspace/agents': 'admin.workspaceAgents',
'/admin/workspace/skills': 'admin.workspaceSkills',
'/admin/workspace/skill-development': 'admin.skillDevelopment',
/* The skill editor, listed exactly for the same reason `agents/new` is: so
`skills/new` is never read as a skill whose id is "new". This is also the
entry the page key is derived from — `pageKeyForRoute` reads the surface
table, and this route is the one the `workspace-skill-configure` surface
declares. The Owliver editor's addresses reach the same context through
the pattern table below; listing them here too would overwrite that key
with a path tail. */
'/admin/workspace/skills/new': 'admin.skillConfigure',
},
};
@@ -35,9 +69,10 @@ const PLACEMENT = {
* Pages that must never carry the assistant, listed explicitly so the intent is
* documented rather than implied by omission.
*
* Admin: login only. Every other surface — including Profile, where the
* questions are about the account rather than the workforce — carries
* the panel.
* Admin: login only. Every other surface — including Profile and Settings,
* where the questions are about the account rather than the workforce,
* and the workspace pages, where they are about the configuration —
* carries the panel.
* 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.
@@ -63,10 +98,70 @@ export const EXCLUDED_ROUTES = [
*/
export const PLACEMENT_ROUTES = PLACEMENT.admin;
/**
* Routes whose address carries a record id.
*
* The table above is matched exactly, which is deliberate and stays that way:
* `/candidates` must not leak an assistant onto `/candidates/:id`. But an agent
* is configured at `/admin/workspace/agents/<id>`, and no exact table can list
* an address that contains an id nobody has created yet.
*
* So a second, much smaller table is consulted **only after the exact lookup
* misses**. Every one of the eight operational pages is an exact match and
* never reaches this code, so their placement is unchanged by construction
* rather than by care.
*
* `exclude` keeps a sibling literal route — `agents/new` is the same screen and
* is listed exactly — from being read as an id.
*/
const PLACEMENT_PATTERNS = {
admin: [
{
/* Agent configuration: one path segment after `agents/`, and not a
nested route beneath it. */
test: (pathname) => /^\/admin\/workspace\/agents\/[^/]+$/.test(pathname),
contextId: 'admin.agentConfigure',
},
{
/* The Owliver skill editor — `skills/owliver/new` and
`skills/owliver/<id>`. Two segments, so it is matched before the
one-segment pattern below and can never be read as a skill whose id is
"owliver". */
test: (pathname) => /^\/admin\/workspace\/skills\/owliver\/[^/]+$/.test(pathname),
contextId: 'admin.skillConfigure',
},
{
/* The Board skill editor: one segment after `skills/`, and no deeper.
`owliver` is excluded because it is a prefix rather than a skill, and
the product has no page at that address. */
test: (pathname) => /^\/admin\/workspace\/skills\/(?!owliver$)[^/]+$/.test(pathname),
contextId: 'admin.skillConfigure',
},
],
};
/**
* The dynamic routes, as literal examples.
*
* Exported so the registry and the checks can reason about a pattern without
* re-implementing it. These are addresses the pattern genuinely matches.
*/
export const PLACEMENT_PATTERN_ROUTES = {
'/admin/workspace/agents/:id': 'admin.agentConfigure',
'/admin/workspace/skills/:id': 'admin.skillConfigure',
'/admin/workspace/skills/owliver/new': 'admin.skillConfigure',
'/admin/workspace/skills/owliver/:id': 'admin.skillConfigure',
};
/** Returns the context for a role and path, or `null` to render no assistant. */
export function resolveAssistantContext(role, pathname) {
/* Exact first, always. The eight operational pages resolve here and never
reach the patterns below. */
const id = PLACEMENT[role]?.[pathname];
return id ? ASSISTANT_CONTEXTS[id] : null;
if (id) return ASSISTANT_CONTEXTS[id] ?? null;
const pattern = (PLACEMENT_PATTERNS[role] || []).find((p) => p.test(pathname));
return pattern ? ASSISTANT_CONTEXTS[pattern.contextId] ?? null : null;
}
/** Every enabled role/route pair — used by the placement verification. */

View File

@@ -27,7 +27,52 @@
*/
import { getContext } from './contexts';
import { toSnapshots } from './blocks';
import { note, toSnapshots } from './blocks';
/**
* An answer, at the depth the agent asked for.
*
* Three modes, and only two of them do anything: `balanced` is today's
* behaviour exactly, so every existing answer and every agent that declares no
* reasoning is unchanged.
*
* `webSearch` is handled here too, and handled honestly. No search provider is
* configured in this deployment, so an agent with the flag set gets a note
* saying so rather than an answer that quietly came from nowhere. Silently
* ignoring the flag would be worse: the configuration would read as working.
*/
function shapeForDepth(document, agent, facts) {
if (!agent || !document?.blocks?.length) return document;
const blocks = [...document.blocks];
if (agent.reasoning === 'fast') {
/* The headline and the first supporting block. Enough to answer, without
the breakdown someone asking a quick question did not want. */
const trimmed = blocks.slice(0, 2);
return { ...document, blocks: trimmed.length ? trimmed : blocks };
}
if (agent.reasoning === 'deep') {
const counted = [
facts?.applications?.length != null && `${facts.applications.length} applications`,
facts?.postings?.length != null && `${facts.postings.length} positions`,
facts?.staff?.length != null && `${facts.staff.length} hires`,
].filter(Boolean);
if (counted.length) {
blocks.push(note(`Read from ${counted.join(', ')} on this page.`));
}
}
if (agent.webSearch) {
blocks.push(note(
'Web search is enabled on this agent, but no search provider is configured '
+ 'in this deployment, so nothing outside this workspace was consulted.'
));
}
return { ...document, blocks };
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -41,7 +86,7 @@ export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
return {
id: 'local',
async *stream({ contextId, capability, question, facts, signal }) {
async *stream({ contextId, capability, question, facts, agent = null, signal }) {
const context = getContext(contextId);
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
@@ -50,12 +95,25 @@ export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
?? context.respond(question, facts)
: context.respond(question, facts);
/**
* The agent's reasoning mode, as answer depth.
*
* A real configuration effect rather than a label: `fast` returns the
* headline and stops, `deep` says which records the reading counted.
* `balanced` — the default, and what an agent that declares nothing gets
* — is untouched, so this cannot change any existing answer.
*
* Depth never changes *what* was read. The page decided that before the
* provider was called; this only decides how much of it to say.
*/
const shaped = shapeForDepth(document, agent, facts);
// A brief pause before the first frame, so the answer reads as considered
// rather than precomputed.
await sleep(latency);
if (signal?.aborted) return;
for (const snapshot of toSnapshots(document)) {
for (const snapshot of toSnapshots(shaped)) {
if (signal?.aborted) return;
yield snapshot;
await sleep(frameDelay);
@@ -83,11 +141,15 @@ export function createHttpProvider({ endpoint, headers = {} }) {
return {
id: 'http',
async *stream({ contextId, capability, question, signal }) {
async *stream({ contextId, capability, question, agent = null, owliverContext = null, signal }) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ contextId, capability, question }),
/* `agent` and `owliverContext` travel; the fact sheet still does not.
Dashboard records are read server-side from the caller's own session,
so the client cannot ask about records it is not entitled to see —
and an agent cannot widen that by being named in the body. */
body: JSON.stringify({ contextId, capability, question, agent, owliverContext }),
signal,
});

View File

@@ -178,6 +178,33 @@ export function outOfScopeAnswer(context) {
);
}
/**
* The reply when the selected agent does not cover this page.
*
* Says three things, because leaving any of them out invites the reader to
* assume something untrue: which agent is active, that the *page* is what
* bounds the answer rather than the agent being broken, and which agent is the
* one for here.
*
* It deliberately does not answer from the requested agent's own pages. An
* agent is a lens on the page you are standing on, never a way to reach another
* one — reaching would make selecting an agent a way around the page boundary,
* which is the one thing the design does not permit.
*/
export function constrainedAgentAnswer(agent, context, suggestion) {
const covered = (agent?.pages || []).join(', ');
return doc(
text(`**${agent?.name || 'That agent'}** is selected, but it does not cover **${context?.page || 'this page'}**.`),
note(covered
? `It works on: ${covered}.`
: 'It covers no pages yet.'),
text(suggestion
? `On this page, **${suggestion.name}** is the one that answers. Switch to it, or open a page ${agent?.name || 'this agent'} covers.`
: 'Open a page it covers, or ask something this page can answer.'),
note('An agent narrows what can be asked here. It never widens it, so the records this page holds are the records any agent can read from it.')
);
}
/**
* Resolve a question to an action, ahead of the page responder.
*
@@ -185,6 +212,7 @@ export function outOfScopeAnswer(context) {
* { kind: 'answer' } — let this page respond
* { kind: 'navigate', destination, doc } — other page; go there
* { kind: 'outOfScope', doc } — nothing here can answer it
* { kind: 'constrained', doc } — the agent does not cover this page
*/
/* ── Workforce ──────────────────────────────────────────────────────────── */
@@ -820,6 +848,16 @@ function resolveDraftAction(question, workforce, positionId = null) {
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [], workforce = null, skillContext = null,
/**
* The active agent, and where the reader is.
*
* Both optional and both inert when absent, so a caller that knows nothing
* about agents resolves exactly as it always did. `agent` does not gate the
* skills considered here — that has already happened, in `disabledSkills`,
* which arrives carrying the agent's scoping. What it decides is whether this
* agent should be answering on this page at all.
*/
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
/* The record the control that raised this question was built from, when there
was one. Only the draft flow reads it; a typed question carries none and
resolves exactly as it always did. */
@@ -827,6 +865,18 @@ export function resolveIntent({
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
/**
* 0. The selected agent does not belong here.
*
* Ahead of everything, because every matcher below would otherwise answer
* from this page while the header names an agent that does not cover it — an
* answer attributed to the wrong lens. The page still decides what is
* readable; this only declines to pretend the agent chose it.
*/
if (agent && !agentCoversPage) {
return { kind: 'constrained', agent, doc: constrainedAgentAnswer(agent, context, agentSuggestion) };
}
/**
* 1. Finishing a draft, wherever the reader is standing.
*
@@ -846,7 +896,10 @@ export function resolveIntent({
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
skillContext,
/* The envelope travels beside the collections rather than replacing them:
a resolver reads records, and the envelope says where the reader is. A
source that needs a position still finds it exactly where it always was. */
skillContext: owliverContext ? { ...skillContext, owliver: owliverContext } : skillContext,
});
if (skill) return skill;

View File

@@ -19,9 +19,11 @@ import {
interviewDone, interviewFailed,
} from '@/lib/skills/workforceFlow';
import {
newConversationId, readHistory, removeConversation, saveConversation,
newConversationId, readHistory, recordFeedback, removeConversation, saveConversation,
} from './history';
import { storableContext } from '@/lib/agents/context';
import { buildFacts } from './insights';
import { agentRequest } from '@/lib/agents/runtime';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
import { toSnapshots } from './blocks';
@@ -233,11 +235,33 @@ export function useConversation({
onUpdatePosition, onGenerateDescription,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [], skillContext = null,
/**
* The active agent and where the reader is.
*
* Both optional. `disabledSkills` already arrives carrying the agent's
* scoping — the caller applies `agentScopedDisabled` before handing it over —
* so nothing here decides what may be read. These are for attribution: which
* agent answered, and whether it belongs on this page.
*/
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
/* The page's own name, recorded with an archived thread so History can say
where a conversation happened without resolving the context again. */
pageLabel = '',
}) {
const storageKey = `krow_assistant:${contextId}`;
/**
* One thread per agent per page.
*
* Switching agent starts a new conversation rather than continuing the last
* one under a different name: the answers in a thread were produced by a
* particular agent's scope, and appending a differently-scoped reply to them
* would make the thread a record of something that never happened.
*
* With no agent the key is exactly what it was, so an existing stored thread
* is still found.
*/
const storageKey = agent?.id
? `krow_assistant:${agent.id}:${contextId}`
: `krow_assistant:${contextId}`;
const flowKey = `${storageKey}:flow`;
const idKey = `${storageKey}:id`;
@@ -267,6 +291,26 @@ export function useConversation({
silently abandon a position half-described. */
const flowRef = React.useRef(null);
/**
* What this conversation actually used.
*
* Accumulated as turns run, never predicted from what was available. A skill
* lands here when it *answered*; a tool when `runAction` was asked to perform
* it. That distinction is the whole value of the record — a list of what the
* page offered would describe the registry, and the insight figures built on
* it would describe the registry too.
*
* A ref rather than state: nothing re-renders when it changes, and it is read
* only at the moment a thread is written.
*/
const usedRef = React.useRef({ skills: [], tools: [], knowledge: [] });
const noteUsed = React.useCallback((kind, id) => {
if (!id) return;
const bucket = usedRef.current[kind];
if (bucket && !bucket.includes(id)) bucket.push(id);
}, []);
const setFlow = React.useCallback((flow) => {
flowRef.current = flow || null;
try {
@@ -298,6 +342,9 @@ export function useConversation({
}
setPending(null);
setError(null);
/* A different page or agent is a different conversation, so what the last
one used does not carry over. */
usedRef.current = { skills: [], tools: [], knowledge: [] };
}, [storageKey, flowKey, idKey]);
/**
@@ -333,8 +380,14 @@ export function useConversation({
contextId,
page: pageLabel,
messages: next,
agentId: agent?.id ?? null,
/* Where the question was asked, reduced — see `storableContext`. */
pageContext: owliverContext ? storableContext(owliverContext) : null,
skillsUsed: usedRef.current.skills,
toolsUsed: usedRef.current.tools,
knowledgeUsed: usedRef.current.knowledge,
}));
}, [storageKey, idKey, contextId, pageLabel]);
}, [storageKey, idKey, contextId, pageLabel, agent, owliverContext]);
/* Abort any in-flight response when the context changes or we unmount. */
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
@@ -387,6 +440,7 @@ export function useConversation({
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
courses, workforce, skillContext, positionId,
agent, agentCoversPage, agentSuggestion, owliverContext,
});
}
@@ -570,6 +624,11 @@ export function useConversation({
collection on every turn — including the turn that starts it. */
if ('flow' in intent) setFlow(intent.flow);
/* What this turn used, recorded from the resolved intent rather than from
the page's offer. `intent.skill` is the definition that answered. */
noteUsed('skills', intent.skill?.id);
noteUsed('tools', intent.action?.name);
if (intent.kind !== 'answer') {
try {
await streamDocument({
@@ -606,6 +665,10 @@ export function useConversation({
let latest = [];
for await (const snapshot of provider.stream({
contextId, capability, question: text, facts, signal: controller.signal,
/* What the agent *is*, never what it may read. The page settled that
before this call, and `agentRequest` carries no records. */
agent: agent ? agentRequest(agent, contextId) : null,
owliverContext,
})) {
if (controller.signal.aborted) break;
latest = snapshot;
@@ -708,6 +771,26 @@ export function useConversation({
if (conversationRef.current === id) reset();
}, [reset]);
/**
* Rates the conversation on screen.
*
* The conversation rather than the turn: a reader judging an answer is
* judging the exchange that produced it, and a per-turn rating would ask them
* to score a paragraph out of context. Returns false when there is nothing to
* rate yet, so a caller can stay honest rather than pretending it landed.
*/
const submitFeedback = React.useCallback((rating, noteText = '') => {
if (!conversationRef.current) return false;
setHistory(recordFeedback(conversationRef.current, rating ? { rating, note: noteText } : null));
return true;
}, []);
/** How the conversation on screen is currently rated, or null. */
const feedback = React.useMemo(
() => history.find((r) => r.id === conversationRef.current)?.feedback ?? null,
[history]
);
return {
messages,
pending,
@@ -716,6 +799,8 @@ export function useConversation({
send,
stop,
reset,
submitFeedback,
feedback,
/** Every archived conversation, newest first. */
history,
/** The conversation on screen, so History can mark it. */

View File

@@ -80,6 +80,14 @@ export {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
/* Radix Collapsible, already present and previously unexported. The agent
configuration screen is a stack of long sections, and a page that opened all
of them at once would bury the one being edited. */
export {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';

View File

@@ -3,7 +3,7 @@ import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
useApplications, useAssignments, useCourses, useCurrentUser, useInterviews, useJobPostings,
usePreferences, useStaff, useUserActivity, useWorkerProfiles,
usePreferences, useShiftRecords, useStaff, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { allSkills } from '@/lib/skills/registry';
import { sectionsForPage } from '@/lib/skills/uiConfig';
@@ -84,6 +84,9 @@ export function useSkillDataContext(context) {
the matching engine reads availability from assignments, and without them
everybody would look free. */
const { data: assignments = [] } = useAssignments();
/* Shifts worked, missed and overrun, for the attendance and overtime
sources. Same cache the panel reads. */
const { data: shifts = [] } = useShiftRecords();
const { data: user } = useCurrentUser();
const { statesFor } = useWorkforcePaths();
@@ -107,12 +110,13 @@ export function useSkillDataContext(context) {
assignments,
staff,
activity,
shifts,
trainingPaths,
/* `published` belongs here: it is what changes when the reader edits the
form this panel is sitting beside, and leaving it out froze every
section on the first value the page ever published. */
}), [published, context, applications, positions, interviews, courses, workerProfiles, assignments,
staff, activity, trainingPaths]);
staff, activity, shifts, trainingPaths]);
}
/** One declared section, resolved and drawn. */