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;