update owliver agent

This commit is contained in:
2026-08-24 20:06:11 +05:30
parent 02eb48af99
commit 6d8b4dbf36
28 changed files with 3176 additions and 692 deletions

View File

@@ -1,180 +0,0 @@
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

@@ -9,13 +9,12 @@ import {
SelectValue, Switch, Textarea,
} from '@/components/ds';
import { allSkills } from '@/lib/skills/registry';
import { surfaceFor, SUPPORTED_SKILL_PAGES } from '@/lib/skills/surfaces';
import { DOMAIN_SURFACES, surfaceFor } 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.
@@ -97,9 +96,9 @@ function useActiveSection(ids) {
}
/** @param {any} props */
export function AgentConfigure({ fields, agents, customSkills = [], onChange }) {
const [addingSkills, setAddingSkills] = React.useState(false);
export function AgentConfigure({
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = 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. */
@@ -332,24 +331,62 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
? <>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>
{/**
* Scope, not a page picker.
*
* This was every id in the vocabulary — eighteen chips including
* Settings, Workspace, Skills and Skill Configure — so the
* Activity Agent could be pointed at the workspace configuration
* screen. That is not a choice anyone should be offered: those
* surfaces hold no workforce records (they declare
* `placements: []`, which is what `DOMAIN_SURFACES` reads), and an
* agent aimed at one has nothing to answer from.
*
* A shipped agent is a *registered domain agent* — its scope is
* part of the definition Krow ships, and the skills it may carry
* are derived from it. So it is stated rather than edited. An
* agent this workspace authored still chooses, from the pages
* that are actually product domains.
*/}
{scopeLocked ? (
<div className="flex flex-wrap gap-1.5">
{fields.pages.map((page) => (
<span
key={page}
className="rounded-full border border-krow-blue/40 bg-krow-blue-tint px-2.5 py-1 text-caption font-medium text-krow-blue"
>
{pageLabel(page)}
</span>
))}
{!fields.pages.length && (
<span className="text-body-sm text-ink-3">No surface registered.</span>
)}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{/* Domain surfaces, plus anything this definition already
declares — so an agent written by hand against a
configuration surface keeps it rather than losing it
silently on the next save. */}
{[...new Set([...DOMAIN_SURFACES, ...fields.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.
{scopeLocked
? 'This agent ships with Krow, and its surface is part of that definition. Its scope decides which capabilities it can carry.'
: 'A page decides which skills exist there. An agent chooses among them — it can narrow that list, never widen it.'}
</p>
</div>
@@ -361,12 +398,16 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
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 variant="outline" size="sm" onClick={onOpenSkills}>
<Boxes className="h-3.5 w-3.5" /> Browse catalog
</Button>
)}
/>
{/* Attaching happens in the Skills workspace, not here. A
checkbox list could say which skills exist; it could not show
what an agent is *made of*, which is the question someone
composing one is actually asking. */}
{fields.skills.length > 0 ? (
<ul className="divide-y divide-border">
{fields.skills.map((id) => {
@@ -386,18 +427,40 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
</ul>
) : (
<p className="text-body-sm text-ink-3">
No skills attached. This agent can still answer from the page&apos;s own reader.
No skills attached. This agent can still answer from the page&apos;s own reader —
or open the{' '}
<button
type="button"
onClick={onOpenSkills}
className={`font-medium text-krow-blue underline-offset-2 hover:underline
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/40 rounded`}
>
skill catalog
</button>{' '}
to give it one.
</p>
)}
<p className="mt-2 text-caption text-ink-4">
Authored in{' '}
Attached under{' '}
<button
type="button"
onClick={onOpenSkills}
className={`font-medium text-krow-blue underline-offset-2 hover:underline
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/40 rounded`}
>
Skills
</button>
, where Owliver skills and Board skills both live. Definitions are written in the{' '}
<Link
to="/admin/workspace/skills"
className="font-medium text-krow-blue underline-offset-2 hover:underline"
>
Workspace &rarr; Skills
skill library
</Link>
.
</p>
</div>
@@ -666,14 +729,6 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
</div>
</DocSection>
</Workspace>
<AddSkillsModal
open={addingSkills}
onOpenChange={setAddingSkills}
attached={fields.skills}
customSkills={customSkills}
onAdd={(ids) => set({ skills: [...fields.skills, ...ids] })}
/>
</>
);
}

View File

@@ -0,0 +1,434 @@
import * as React from 'react';
import { ListTree, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Alert, Button, Drawer, Tabs } from '@/components/ds';
import { surfaceFor } from '@/lib/skills/surfaces';
import { evaluateCapability, scopeFor, testTargets } from '@/lib/agents/capabilityTest';
import { supportedSurfaces } from './SurfaceSelect';
import {
boardCatalog, catalogGroups, filterBoard, filterCatalog, skillCatalog,
} from '@/lib/skills/catalog';
import { AgentTree } from './AgentTree';
import { BoardSkillList } from './BoardSkillList';
import { SkillCatalog } from './SkillCatalog';
import { SkillDetails } from './SkillDetails';
/**
* Composing an agent: what it is made of, beside what it could be made of.
*
* Owliver already holds the third column on every Admin page, so this screen
* completes the workspace rather than rebuilding it — tree, catalog, and the
* assistant that was already there. That is the reason there is no chat in this
* file and no route out of it: attaching a skill is a thing you do *while*
* talking to Owliver, and a second conversation would be a second Owliver.
*
* **One source of truth.** `attachedIds` is derived from the agent's own
* `skills` field on every render, and the tree, the cards and the details panel
* all read that one set. Nothing here caches "is this attached" — which is how a
* card and a tree come to disagree after a failed save.
*
* The tree is a column at `lg` and a drawer below it. One tree either way: the
* component is rendered once per presentation but its open/closed branch state
* lives here, so opening Tools on a phone and rotating to a tablet does not shut
* it again.
*
* **One Skills section, two kinds.** Owliver skills and Board skills are two
* tabs of this one surface rather than two destinations, because the question
* "which of those do I go to?" was the whole problem. They are not the same
* thing behind the tabs, and the panels say so: an Owliver skill is attached to
* this agent, a Board skill is switched on for a page. Flattening that
* difference would have made the navigation simpler and the product a liar.
*/
const OWLIVER = 'owliver';
const BOARD = 'board';
/** @param {any} props */
export function AgentSkillWorkspace({
fields,
customSkills = [],
onToggleSkill,
disabledSkills = [],
onToggleBoardSkill,
onTestCapability,
test = null,
busy = false,
pendingId = null,
dirty = false,
}) {
const [kind, setKind] = React.useState(OWLIVER);
const [query, setQuery] = React.useState('');
const [boardQuery, setBoardQuery] = React.useState('');
const [group, setGroup] = React.useState('all');
const [selectedId, setSelectedId] = React.useState(/** @type {string|null} */ (null));
const [treeOpen, setTreeOpen] = React.useState(false);
const [branches, setBranches] = React.useState({
surfaces: false,
skills: true,
'skills-owliver': true,
'skills-board': true,
tools: true,
knowledge: false,
});
/**
* Only what this agent could actually use.
*
* The catalog used to be the whole registry, so the Activity Agent was
* offered Create Position — an attachment the runtime would have refused,
* because `skillsForContext` filters by page before an agent's own list is
* consulted. Passing the scope makes the catalog agree with the runtime.
*/
const entries = React.useMemo(
() => skillCatalog(customSkills, { pages: fields.pages }),
[customSkills, fields.pages]
);
const board = React.useMemo(
() => boardCatalog(customSkills, { pages: fields.pages }),
[customSkills, fields.pages]
);
const disabledIds = React.useMemo(() => new Set(disabledSkills), [disabledSkills]);
/* The pages this agent answers on, in the product's own words. */
const surfaces = React.useMemo(
() => fields.pages.map((p) => ({ id: p, label: surfaceFor(p)?.label || p })),
[fields.pages]
);
/* `boardCatalog` is already scoped, so every entry draws somewhere this agent
answers. Kept as its own name because the tree and the tab count read it. */
const boardHere = board;
const visibleBoard = React.useMemo(
() => filterBoard(board, { query: boardQuery }),
[board, boardQuery]
);
const entryById = React.useMemo(
() => new Map(entries.map((e) => [e.id, e])),
[entries]
);
const attachedIds = React.useMemo(() => new Set(fields.skills), [fields.skills]);
const groups = React.useMemo(() => catalogGroups(entries), [entries]);
const visible = React.useMemo(
() => filterCatalog(entries, { query, group }),
[entries, query, group]
);
/**
* The tools this agent reaches.
*
* Derived from the attached skills' own declarations, never stored — a tool is
* not something an agent carries, it is something a skill it carries can do.
* Deduplicated by name, keeping the first skill that brought it so the tree can
* say where it came from.
*/
const tools = React.useMemo(() => {
const seen = new Map();
for (const id of fields.skills) {
const entry = entryById.get(id);
if (!entry) continue;
for (const tool of entry.tools) {
if (!seen.has(tool.name)) seen.set(tool.name, { ...tool, via: entry.name });
}
}
return [...seen.values()];
}, [fields.skills, entryById]);
/* A skill attached to this agent that the registry does not have. Worth
saying out loud: it is the one state where the tree shows something the
runtime cannot use. */
const unknown = React.useMemo(
() => fields.skills.filter((id) => !entryById.has(id)),
[fields.skills, entryById]
);
const toggleBranch = React.useCallback(
(id) => setBranches((current) => ({ ...current, [id]: !current[id] })),
[]
);
const openDetails = React.useCallback((id) => setSelectedId(id), []);
/**
* The surface a capability runs on when nobody has chosen one.
*
* The first its definition supports — Owliver for everything shipped today.
* The card has no selector, so this is what `+ Add Agent` and its Test button
* mean by "this capability"; the drawer overrides it with whatever the reader
* picked there.
*/
const defaultSurface = React.useCallback(
(entry) => supportedSurfaces(entry.type)[0] || 'owliver',
[]
);
/**
* Trying a capability — the one implementation.
*
* Both the card's Test button and the drawer's call this, so a test started
* from either place resolves the same target page, builds the same scope and
* hands the same request to the Owliver panel. Two copies of this would be two
* definitions of what "test" means.
*
* It writes nothing. `evaluateCapability` reasons about a hypothetical agent
* carrying the capability, and that agent is never stored — which is what
* keeps Test and Add Agent separate acts.
*/
const runCapabilityTest = React.useCallback(({ entry, surface, question = null }) => {
if (!onTestCapability) return;
const target = testTargets(fields.pages, entry.surfaces.map((x) => x.id))[0];
/* A Board section draws on a page rather than answering, so there is no
question to ask it. */
if (!target || surface === 'board') return;
const asked = question
|| entry.suggestions?.[0]?.prompt
|| entry.suggestions?.[0]?.label
|| entry.name;
const evaluation = evaluateCapability({
fields, skillId: entry.id, contextId: target.contextId, question: asked, customSkills, entry,
});
onTestCapability({
question: asked,
capability: evaluation.capability,
scope: scopeFor(evaluation, target.contextId),
trace: { skillId: entry.id, skillName: entry.name, agentName: fields.name, surface },
});
}, [fields, customSkills, onTestCapability]);
/* Which capabilities have somewhere to be tried, so the card can disable a
button that would do nothing rather than offering it and failing. */
const testableIds = React.useMemo(() => new Set(
entries
.filter((e) => e.type !== 'board'
&& testTargets(fields.pages, e.surfaces.map((x) => x.id)).length)
.map((e) => e.id)
), [entries, fields.pages]);
/**
* Is this capability on, for a given surface?
*
* The two surfaces have two different, *real* switches, and this reads both
* rather than inventing a third state to sit over them. Owliver is the
* agent's own `skills` list. Board is the account's switched-off list, which
* is what `SkillSurface` actually consults — workspace-wide, which the detail
* panel says out loud rather than dressing up as per-agent.
*/
const isEnabledOn = React.useCallback((id, surf) => {
const onOwliver = attachedIds.has(id);
const onBoard = !disabledIds.has(id);
if (surf === 'board') return onBoard;
if (surf === 'both') return onOwliver && onBoard;
return onOwliver;
}, [attachedIds, disabledIds]);
/**
* Enable or disable, on the surface the reader chose.
*
* Routes to the existing persistence for each half — no new store, and no
* record of "which surface this was enabled as", because there does not need
* to be one: the two switches *are* that record, and a derived state cannot
* drift from them.
*/
const onToggleSurface = React.useCallback((id, surf) => {
const wantOn = !isEnabledOn(id, surf);
if (surf === 'owliver' || surf === 'both') {
if (attachedIds.has(id) !== wantOn) onToggleSkill(id);
}
if (surf === 'board' || surf === 'both') {
if (!disabledIds.has(id) !== wantOn) onToggleBoardSkill(id, wantOn);
}
}, [isEnabledOn, attachedIds, disabledIds, onToggleSkill, onToggleBoardSkill]);
/**
* `+ Add Agent`, from a card.
*
* The card carries no surface selector, so it assigns on the capability's
* default surface — through the *same* `onToggleSurface` the drawer's Enable
* uses, so there is one assignment path and nothing new to persist. It asks
* Owliver nothing: adding and testing are separate acts.
*/
const addToAgent = React.useCallback(
(entry) => onToggleSurface(entry.id, defaultSurface(entry)),
[onToggleSurface, defaultSurface]
);
/** `Test in Owliver`, from a card. Adds nothing — see `runCapabilityTest`. */
const testFromCard = React.useCallback(
(entry) => runCapabilityTest({ entry, surface: defaultSurface(entry) }),
[runCapabilityTest, defaultSurface]
);
/* Selecting from the tree on a phone closes the tree: the details panel is
about to cover it, and leaving two overlays stacked is how a reader ends up
dismissing one and finding another. */
const selectFromTree = React.useCallback((id) => {
setSelectedId(id);
setTreeOpen(false);
}, []);
const selected = selectedId ? entryById.get(selectedId) || null : null;
const tree = (
<AgentTree
agentName={fields.name}
agentIcon={fields.icon}
surfaces={surfaces}
skills={fields.skills}
boardSkills={boardHere}
tools={tools}
knowledge={fields.knowledge}
entryById={entryById}
selectedId={selectedId}
onSelect={selectFromTree}
onRemove={onToggleSkill}
open={branches}
onToggleBranch={toggleBranch}
busy={busy}
/>
);
return (
<div className="w-full min-w-0 space-y-3">
{dirty && (
<Alert tone="info" title="This agent has unsaved edits">
Skill changes are being kept in the draft with them. Save the agent to store everything
together.
</Alert>
)}
{unknown.length > 0 && (
<Alert tone="warning" title={`${unknown.length} attached skill${unknown.length === 1 ? '' : 's'} not in the registry`}>
{unknown.join(', ')} — no definition with that id is registered, so it answers nothing.
Remove it from the tree, or restore the definition.
</Alert>
)}
<div className="flex min-w-0 items-start gap-4">
{/* The tree as a column, once there is genuinely room for one. Owliver
holds ~380px on the right of every Admin page, so below `lg` the
remaining width belongs to the catalog and the tree becomes a
drawer. */}
<aside
className={cn(
'hidden w-[15.5rem] shrink-0 lg:block',
/* Sticky at the same offset and height Owliver's own column uses
(`top-14 pt-6`, `100dvh - 6.5rem` in AdminLayout), so the tree and
the assistant are two tracks of one workspace rather than two
panels that happen to be side by side. `dvh` for the reason the
shell uses it: `vh` on a phone is the viewport with the browser
chrome retracted. */
'lg:sticky lg:top-20 lg:max-h-[calc(100dvh-6.5rem)] lg:overflow-y-auto lg:pr-1'
)}
>
<div className="rounded-2xl border border-border bg-surface p-2.5 shadow-xs">
{tree}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-3">
<div className="lg:hidden">
<Button
variant="outline"
size="sm"
onClick={() => setTreeOpen(true)}
className="w-full sm:w-auto"
>
<ListTree className="h-3.5 w-3.5" />
Agent tree
<span className="tabular-nums text-ink-4">
· {fields.skills.length} skill{fields.skills.length === 1 ? '' : 's'}
</span>
</Button>
</div>
{/* One Skills heading, two kinds under it. Underline tabs rather
than a segmented control: these are two lists of a section, and
the console already uses this variant for exactly that on the
agent editor above. */}
<Tabs
variant="underline"
size="sm"
ariaLabel="Skill kind"
/* The tablist does not wrap, and "Owliver Skills" + "Board Skills"
with their counts is wider than a 320px column. Scrolling the
strip is what keeps that from becoming horizontal page overflow. */
className="-mx-0.5 flex-nowrap overflow-x-auto px-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
value={kind}
onChange={setKind}
tabs={[
{ value: OWLIVER, label: 'Owliver Skills', count: fields.skills.length },
{ value: BOARD, label: 'Board Skills', count: boardHere.length },
]}
/>
{kind === OWLIVER ? (
<SkillCatalog
entries={visible}
groups={groups}
total={entries.length}
query={query}
onQueryChange={setQuery}
group={group}
onGroupChange={setGroup}
attachedIds={attachedIds}
onOpen={openDetails}
onAddAgent={addToAgent}
onTest={testFromCard}
testableIds={testableIds}
busy={busy}
pendingId={pendingId}
/>
) : (
<BoardSkillList
entries={visibleBoard}
total={board.length}
agentPages={fields.pages}
disabledIds={disabledIds}
onToggle={onToggleBoardSkill}
busy={busy}
query={boardQuery}
onQueryChange={setBoardQuery}
/>
)}
</div>
</div>
<Drawer
open={treeOpen}
onOpenChange={setTreeOpen}
side="left"
size="sm"
title="Agent"
description="What this agent is made of."
icon={ListTree}
footer={(
<Button variant="outline" onClick={() => setTreeOpen(false)} className="w-full">
<X className="h-3.5 w-3.5" /> Close
</Button>
)}
>
{tree}
</Drawer>
<SkillDetails
entry={selected}
open={Boolean(selected)}
onOpenChange={(next) => { if (!next) setSelectedId(null); }}
isEnabledOn={isEnabledOn}
onToggle={onToggleSurface}
busy={busy}
fields={fields}
onRunTest={runCapabilityTest}
test={test}
/>
</div>
);
}
export default AgentSkillWorkspace;

View File

@@ -0,0 +1,329 @@
import * as React from 'react';
import {
AlertTriangle, BookOpen, ChevronRight, LayoutGrid, LayoutTemplate, Wrench, X, Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { glyphForGroup } from './glyphs';
/**
* What this agent is made of.
*
* A tree because the thing genuinely is one — an agent carries skills, those
* skills reach tools, and the agent is told things. The shape is not decoration:
* every branch below is *derived* from the one above it, so the tree cannot show
* a relationship the runtime does not have.
*
* The Tools branch is the clearest case and the reason this is worth drawing at
* all. Tools are not attached to an agent and never have been — `tools.js` reads
* what the reachable *skills* declare. Listing them as a child of Skills is
* therefore the true picture, and it is what makes "attach Anomaly Detection"
* legible as "this agent can now open Activity" without anyone writing that
* sentence down.
*
* Not a `role="tree"`. That pattern owes the reader arrow-key navigation and a
* roving tabindex, and claiming the role without the keyboard contract is worse
* than not claiming it: a screen reader announces a widget that does not behave
* like one. This is a nested list of real buttons — Tab reaches every control,
* Enter and Space work because they are buttons, and `aria-expanded` says what
* each disclosure is doing.
*
* **Everything hangs off the agent.** There is no branch here for "Owliver
* Agents", "Owliver Skills" or "Board Skills" as peers — that was the shape
* that made someone ask which of three places to go. Skills is one branch with
* the two kinds nested inside it, which is the sentence the product wants read:
* an agent has skills, and skills come in two kinds.
*/
/**
* A branch header: the disclosure, its label, and how much is inside.
*
* `hint` is what an empty branch says. `sub` renders it one step quieter, which
* is how Owliver Skills and Board Skills read as *kinds of* Skills rather than
* as two more top-level things.
*/
function Branch({ id, icon: Icon, label, count, open, onToggle, children, hint, sub = false }) {
const panelId = `agent-tree-${id}`;
return (
<li>
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-controls={panelId}
className={`group flex w-full items-center gap-1.5 rounded-lg px-1.5 py-1.5 text-left
transition-colors duration-base hover:bg-surface-sunken
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
open && 'rotate-90'
)}
aria-hidden="true"
/>
<Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span
className={cn(
'min-w-0 flex-1 truncate text-body-sm',
sub ? 'text-ink-3' : 'font-medium text-ink-2'
)}
>
{label}
</span>
<span className="shrink-0 rounded-md bg-surface-subtle px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-ink-3">
{count}
</span>
</button>
{/* Kept mounted so the count above and the list below cannot disagree
about what is inside, and so opening a branch is a height transition
rather than a mount. */}
<div
id={panelId}
className={cn(
'grid transition-[grid-template-rows] duration-base ease-out',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
>
<div className="overflow-hidden" inert={open ? undefined : true}>
<ul className="ml-[0.6875rem] space-y-0.5 border-l border-border/70 pl-2.5 pt-0.5">
{children}
{count === 0 && (
<li className="px-1.5 py-1.5 text-caption leading-snug text-ink-4">{hint}</li>
)}
</ul>
</div>
</div>
</li>
);
}
/**
* One skill on the agent.
*
* Two controls, never one: the row selects the skill so its details can be read,
* and the cross detaches it. A single control doing both is how a reader loses a
* skill while trying to look at it.
*/
function SkillLeaf({ entry, id, selected, onSelect, onRemove, busy }) {
const Glyph = entry ? glyphForGroup(entry.group) : AlertTriangle;
const name = entry?.name || id;
return (
<li className="group/leaf flex items-stretch gap-0.5">
<button
type="button"
onClick={() => onSelect(id)}
aria-current={selected ? 'true' : undefined}
className={cn(
`flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1.5 py-1.5 text-left
transition-colors duration-base focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
selected
? 'bg-krow-blue-tint text-krow-blue'
: 'text-ink-2 hover:bg-surface-sunken'
)}
>
<Glyph
className={cn(
'h-3.5 w-3.5 shrink-0',
entry ? (selected ? 'text-krow-blue' : 'text-ink-4') : 'text-warning'
)}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-body-sm">{name}</span>
{/* Attachment is stated in words as well as in colour — a tinted row is
not a fact a screen reader can read, and it is not one a reader who
cannot separate the tint from the surface can read either. */}
<span className="sr-only">{selected ? ' — selected' : ''}</span>
{!entry && (
<span className="shrink-0 text-[10px] font-medium text-warning">Unknown</span>
)}
</button>
<button
type="button"
onClick={() => onRemove(id)}
disabled={busy}
aria-label={`Remove ${name} from this agent`}
className={`grid w-6 shrink-0 place-items-center rounded-lg text-ink-4 opacity-0
transition-all duration-base hover:bg-destructive/10 hover:text-destructive
focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/40 disabled:cursor-not-allowed disabled:opacity-40
group-hover/leaf:opacity-100 group-focus-within/leaf:opacity-100`}
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
</li>
);
}
/** A leaf that is only ever read — a tool reached, a note the agent was given. */
function StaticLeaf({ icon: Icon, label, detail = null }) {
return (
<li className="flex items-start gap-1.5 rounded-lg px-1.5 py-1.5">
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm text-ink-2">{label}</span>
{detail && <span className="block truncate text-[10px] text-ink-4">{detail}</span>}
</span>
</li>
);
}
/** @param {any} props */
export function AgentTree({
agentName,
agentIcon,
surfaces = [],
skills = [],
boardSkills = [],
tools = [],
knowledge = [],
entryById,
selectedId,
onSelect,
onRemove,
open,
onToggleBranch,
busy = false,
className,
}) {
const Glyph = agentIconFor(agentIcon);
return (
<nav aria-label="Agent composition" className={cn('min-w-0', className)}>
{/* The root. Not a disclosure: an agent that could be collapsed to
nothing would leave the panel showing a single closed row. */}
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface-subtle/60 px-2.5 py-2">
<span
className="grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-border bg-surface"
aria-hidden="true"
>
{Glyph
? <Glyph className="h-3.5 w-3.5 text-krow-blue" />
: <OwliverAvatar className="h-4 w-4" rounded="rounded-md" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-heading text-body-sm font-semibold text-ink-1">
{agentName || 'New agent'}
</span>
<span className="block text-[10px] uppercase tracking-wide text-ink-4">Agent</span>
</span>
</div>
<ul className="mt-1 space-y-0.5">
{/* Where it answers. First, because it bounds everything under it: a
skill only answers on a page the agent covers. */}
<Branch
id="surfaces"
icon={LayoutGrid}
label="Surfaces"
count={surfaces.length}
open={open.surfaces}
onToggle={() => onToggleBranch('surfaces')}
hint="No page selected — this agent has nowhere to answer yet."
>
{surfaces.map((surface) => (
<StaticLeaf key={surface.id} icon={LayoutGrid} label={surface.label} />
))}
</Branch>
{/* One Skills branch, two kinds inside it. */}
<Branch
id="skills"
icon={Zap}
label="Skills"
count={skills.length + boardSkills.length}
open={open.skills}
onToggle={() => onToggleBranch('skills')}
hint="Nothing yet."
>
<Branch
id="skills-owliver"
icon={Zap}
label="Owliver Skills"
count={skills.length}
open={open['skills-owliver']}
onToggle={() => onToggleBranch('skills-owliver')}
hint="Nothing attached yet. Add one from the catalog."
sub
>
{skills.map((id) => (
<SkillLeaf
key={id}
id={id}
entry={entryById.get(id) || null}
selected={selectedId === id}
onSelect={onSelect}
onRemove={onRemove}
busy={busy}
/>
))}
</Branch>
{/* Read-only, and that is the honest shape: a board skill is switched
on for a page, not carried by an agent, so there is nothing here to
detach. These are the ones drawing on the surfaces above. */}
<Branch
id="skills-board"
icon={LayoutTemplate}
label="Board Skills"
count={boardSkills.length}
open={open['skills-board']}
onToggle={() => onToggleBranch('skills-board')}
hint="None drawing on this agent's surfaces."
sub
>
{boardSkills.map((entry) => (
<StaticLeaf
key={entry.id}
icon={LayoutTemplate}
label={entry.name}
detail={entry.surfaces.map((x) => x.label).join(', ')}
/>
))}
</Branch>
</Branch>
<Branch
id="tools"
icon={Wrench}
label="Tools"
count={tools.length}
open={open.tools}
onToggle={() => onToggleBranch('tools')}
hint="Tools arrive with skills. Attach a skill that declares one."
>
{tools.map((tool) => (
<StaticLeaf key={tool.name} icon={Wrench} label={tool.label} detail={tool.via} />
))}
</Branch>
<Branch
id="knowledge"
icon={BookOpen}
label="Knowledge"
count={knowledge.length}
open={open.knowledge}
onToggle={() => onToggleBranch('knowledge')}
hint="Nothing attached. Add knowledge under Configure."
>
{knowledge.map((entry, i) => (
<StaticLeaf
key={entry.id || `${entry.label}-${i}`}
icon={BookOpen}
label={entry.label || 'Untitled'}
detail={entry.kind}
/>
))}
</Branch>
</ul>
</nav>
);
}
export default AgentTree;

View File

@@ -0,0 +1,227 @@
import * as React from 'react';
import { LayoutTemplate, Pencil, Plus, SearchX } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
/**
* Board skills, under the agent that stands beside them.
*
* The second half of one Skills section — the same heading, a tab away from the
* Owliver catalog — so nobody has to decide between two top-level destinations
* ever again. What it is *not* is a second copy of that catalog with the word
* Board on it, and the reason is worth stating because it is the one thing here
* that could quietly become a lie:
*
* **A Board skill is not carried by an agent.** `SkillSurface` renders a
* section from the page it names and the account's `disabledSkills`; it does not
* read `agent.skills` and has no agent in scope. Writing a Board skill id into
* `agent.skills` would therefore record an assignment nothing honours — a card
* that says "Added" and a product that behaves identically either way.
*
* So the relationship shown is the real one: these are the sections the pages
* *this agent answers on* will draw, and the control offered is the one that
* actually governs them — the workspace switch every surface already reads. It
* is labelled as workspace-wide, because it is.
*/
/** One Board skill: what it draws, where, and whether it is switched on. */
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
const switchId = `board-${entry.id}`;
return (
<li className="min-w-0 h-full">
<article
className={cn(
`group relative flex h-full min-w-0 flex-col justify-between rounded-2xl border bg-surface p-5 text-left
shadow-xs transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-lg overflow-hidden`,
enabled ? 'border-krow-blue/40 hover:border-krow-blue' : 'border-border/90 hover:border-krow-blue/40'
)}
>
{/* Soft Background Highlight on Hover */}
<div
className={cn(
'absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-krow-blue-tint/40 to-transparent pointer-events-none transition-opacity duration-300',
enabled ? 'opacity-70' : 'opacity-0 group-hover:opacity-100'
)}
/>
<div className="relative z-10">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border shadow-2xs transition-colors',
enabled
? 'border-krow-blue/30 bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface-subtle text-ink-3 group-hover:text-krow-blue group-hover:border-krow-blue/30 group-hover:bg-krow-blue-tint/50'
)}
>
<LayoutTemplate className="h-3.5 w-3.5" />
</span>
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
Board Skill
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={cn('text-[11px] font-semibold', enabled ? 'text-emerald-600' : 'text-ink-4')}>
{enabled ? 'Active' : 'Disabled'}
</span>
<Switch
id={switchId}
checked={enabled}
disabled={busy}
onCheckedChange={(next) => onToggle(entry.id, next)}
aria-label={`${entry.name} — ${enabled ? 'on' : 'off'} for this workspace`}
/>
</div>
</div>
<h3 className="font-heading text-body-lg font-bold leading-snug text-ink-1 group-hover:text-krow-blue transition-colors mt-3.5">
{entry.name}
</h3>
{entry.description && (
<p className="mt-1.5 text-body-sm leading-relaxed text-ink-3 line-clamp-2 min-h-[2.5rem]">
{entry.description}
</p>
)}
{/* What it actually draws. */}
{entry.sections.length > 0 && (
<ul className="mt-3 space-y-1.5">
{entry.sections.map((section, i) => (
<li
key={section.id || `${section.page}-${i}`}
className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 rounded-md bg-surface-subtle px-2 py-1 text-[11px] text-ink-3 border border-border/60"
>
<span className="font-medium text-ink-2">{section.title}</span>
<span className="text-ink-4">·</span>
<span>{section.typeLabel}</span>
<span className="text-ink-4">·</span>
<span className={cn(onSurfaces.has(section.page) ? 'font-semibold text-krow-blue' : 'text-ink-3')}>
{section.pageLabel}
</span>
<span className="text-ink-4">/ {section.placementLabel}</span>
</li>
))}
</ul>
)}
</div>
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
<span className="text-caption text-ink-4">
{entry.surfaces.some((s) => onSurfaces.has(s.id))
? 'Active on this agent\'s pages'
: 'Draws across workspace'}
</span>
<Link
to={`/admin/workspace/skills/${entry.id}`}
className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:text-krow-blue-dark transition-colors"
>
<Pencil className="h-3 w-3" aria-hidden="true" /> Edit
</Link>
</div>
</article>
</li>
);
}
/** @param {any} props */
export function BoardSkillList({
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
}) {
const searchId = React.useId();
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
/* The ones that meet this agent first */
const ordered = React.useMemo(() => {
const here = [];
const elsewhere = [];
for (const e of entries) {
(e.surfaces.some((s) => onSurfaces.has(s.id)) ? here : elsewhere).push(e);
}
return [...here, ...elsewhere];
}, [entries, onSurfaces]);
return (
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
<Alert tone="info" title="Board skills belong to a page, not to an agent">
They draw sections on KROW pages and are switched on for the whole workspace —
so a change here affects every agent answering on that page, not just this one.
</Alert>
{total > 0 && (
<div>
<label htmlFor={searchId} className="sr-only">Search board skills</label>
<SearchInput
id={searchId}
value={query}
onChange={onQueryChange}
placeholder="Search board skills, sections or pages..."
debounce={0}
/>
</div>
)}
{total > 0 && (
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 pt-1">
<p className="text-xs font-medium text-ink-3" aria-live="polite">
<span className="font-semibold text-ink-1">{ordered.length}</span> of {total} board skill{total === 1 ? '' : 's'}
</p>
<Link
to="/admin/workspace/skills/new"
className={`inline-flex items-center gap-1.5 rounded-lg border border-border/80 bg-surface px-3 py-1.5
text-xs font-semibold text-krow-blue shadow-2xs transition-all
hover:bg-krow-blue-tint/60 hover:border-krow-blue/40 focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> New board skill
</Link>
</div>
)}
{ordered.length > 0 ? (
<ul className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4.5">
{ordered.map((entry) => (
<BoardCard
key={entry.id}
entry={entry}
enabled={!disabledIds.has(entry.id)}
onToggle={onToggle}
onSurfaces={onSurfaces}
busy={busy}
/>
))}
</ul>
) : (
<div className="rounded-2xl border border-dashed border-border bg-surface-subtle/40 px-4 py-12 text-center">
{total > 0 ? (
<>
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills match that</p>
<p className="mt-1 text-xs text-ink-3">Try a different word.</p>
</>
) : (
<>
<LayoutTemplate className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills yet</p>
<p className="mx-auto mt-1 max-w-[46ch] text-xs leading-relaxed text-ink-3">
A board skill declares a section, where it sits on a page, and what it reads.
Add one and the page it names renders it.
</p>
<Button asChild variant="outline" size="sm" className="mt-3.5">
<Link to="/admin/workspace/skills/new">
<Plus className="h-3.5 w-3.5" /> Add a board skill
</Link>
</Button>
</>
)}
</div>
)}
</section>
);
}
export default BoardSkillList;

View File

@@ -0,0 +1,177 @@
import * as React from 'react';
import { ArrowRight, Check, Layers, LayoutTemplate, Loader2, Play, Plus, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { glyphForGroup } from './glyphs';
/**
* One capability, with the three things you can do to it.
*
* These are three responsibilities and they stay three controls:
*
* **+ Add Agent** assigns the capability to this agent, through the
* existing `agent.skills` persistence. It never asks
* Owliver anything.
* **Test in Owliver** runs the capability's own example question through the
* existing Owliver panel. It never assigns anything.
* **Details →** opens the drawer to read it, choose a surface, and
* enable it deliberately.
*
* Merging any two of them was the mistake a combined `Open / Test` made: it
* left the reader unable to tell whether pressing it would change their agent.
*
* The card is a plain container, not a clickable one. Three real buttons and no
* wrapper that swallows them — which is also why there is no `role="button"` on
* anything here.
*/
/** @type {any} */
export const SkillCard = React.memo(/** @param {any} props */ ({
entry, enabled, onOpen, onAddAgent, onTest, canTest = false, busy = false, isPending = false,
}) => {
const Glyph = glyphForGroup(entry.group);
const samplePrompt = entry.suggestions?.[0]?.label;
return (
<li className="h-full min-w-0">
<article
className={cn(
`group relative flex h-full min-w-0 flex-col justify-between overflow-hidden rounded-2xl
border bg-surface p-5 shadow-xs transition-all duration-300 ease-out hover:shadow-lg`,
enabled
? 'border-krow-blue/40 hover:border-krow-blue'
: 'border-border/90 hover:border-krow-blue/40'
)}
>
<span
className={cn(
'pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-krow-blue-tint/40 to-transparent transition-opacity duration-300',
enabled ? 'opacity-70' : 'opacity-0 group-hover:opacity-100'
)}
aria-hidden="true"
/>
<div className="relative z-10 min-w-0">
{/* Category, and the assignment control. */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border shadow-2xs transition-colors',
enabled
? 'border-krow-blue/30 bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface-subtle text-ink-3 group-hover:border-krow-blue/30 group-hover:bg-krow-blue-tint/50 group-hover:text-krow-blue'
)}
aria-hidden="true"
>
<Glyph className="h-3.5 w-3.5" />
</span>
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
{entry.groupLabel}
</span>
</div>
<button
type="button"
onClick={() => onAddAgent(entry)}
disabled={busy || isPending}
aria-pressed={enabled}
aria-label={enabled
? `Remove ${entry.name} from this agent`
: `Add ${entry.name} to this agent`}
className={cn(
`inline-flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-0.5 text-[11px]
font-semibold transition-all duration-base active:scale-95
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40
disabled:cursor-not-allowed disabled:opacity-50`,
enabled
? 'border-krow-blue/30 bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:bg-krow-blue-tint/40 hover:text-krow-blue'
)}
>
{isPending
? <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
: enabled
? <Check className="h-3 w-3" aria-hidden="true" />
: <Plus className="h-3 w-3" aria-hidden="true" />}
{enabled ? 'Added' : 'Add Agent'}
</button>
</div>
<h3 className="mt-3.5 font-heading text-body-lg font-bold leading-snug text-ink-1">
{entry.name}
</h3>
{entry.description && (
<p className="mt-1.5 line-clamp-2 min-h-[2.5rem] text-body-sm leading-relaxed text-ink-3">
{entry.description}
</p>
)}
{/* One of the capability's own questions — the same suggestion Test
sends, so the card cannot show a question it would not ask. */}
{samplePrompt && (
<p className="mt-3 flex w-full items-center gap-1.5 rounded-lg border border-krow-blue/20 bg-krow-blue-tint/50 px-2.5 py-1.5">
<Sparkles className="h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
<span className="truncate text-[11px] font-medium italic text-ink-2">
&ldquo;{samplePrompt}&rdquo;
</span>
</p>
)}
<ul className="mt-3 flex flex-wrap items-center gap-1.5">
{entry.surfaces?.map((s) => (
<li
key={s.id}
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-surface-subtle px-2 py-0.5 text-[11px] font-medium text-ink-2"
>
<Layers className="h-3 w-3 text-ink-4" aria-hidden="true" />
{s.label}
</li>
))}
{entry.type === 'both' && (
<li className="inline-flex items-center gap-1 rounded-md border border-krow-blue/30 bg-krow-blue-tint px-2 py-0.5 text-[11px] font-semibold text-krow-blue">
<LayoutTemplate className="h-3 w-3" aria-hidden="true" />
Board &amp; Owliver
</li>
)}
</ul>
</div>
{/* Test and Details — neither of them changes the agent. */}
<div className="relative z-10 mt-4 flex flex-wrap items-center justify-between gap-x-2 gap-y-1.5 border-t border-border/60 pt-3">
<button
type="button"
onClick={() => onTest(entry)}
disabled={!canTest || busy}
title={canTest ? undefined : 'This capability answers on no page this agent covers.'}
className={`inline-flex items-center gap-1.5 rounded text-caption font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/40
disabled:cursor-not-allowed disabled:text-ink-4`}
>
<Play className="h-3 w-3 fill-current" aria-hidden="true" />
Test in Owliver
</button>
<button
type="button"
onClick={() => onOpen(entry.id)}
className={`inline-flex items-center gap-1 rounded 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/40`}
>
Details
<ArrowRight
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</div>
</article>
</li>
);
});
SkillCard.displayName = 'SkillCard';
export default SkillCard;

View File

@@ -0,0 +1,148 @@
import * as React from 'react';
import { Plus, SearchX } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { SearchInput } from '@/components/ds';
import { SkillCard } from './SkillCard';
/**
* The catalog — every skill this workspace has, browsable.
*
* The centre of the screen and the whole point of the redesign: the previous
* dialog asked "which of these checkboxes do you want", which is a question
* about a list. This asks "what should this agent be able to do", which is a
* question about the agent — so the categories are Krow's own domains, the
* cards say what a skill answers rather than how many pages it names, and
* whether a skill is already attached is on the card instead of behind a second
* dialog.
*
* The grid is `auto-fill` rather than breakpoint columns. This lives beside a
* tree and beside Owliver, so its width is not the viewport's — a `lg:grid-cols-3`
* would be three columns of 140px at 1280px with both neighbours open. Sizing
* from the track means the column count follows the space the catalog actually
* has, at every width, with no media query to get wrong.
*/
/** @param {any} props */
function CategoryFilter({ groups, value, onChange, total }) {
return (
<div
role="group"
aria-label="Filter skills by category"
className="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible"
>
{[{ id: 'all', label: 'All', count: total }, ...groups].map((group) => {
const active = value === group.id;
return (
<button
key={group.id}
type="button"
onClick={() => onChange(group.id)}
aria-pressed={active}
title={group.blurb || undefined}
className={cn(
`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5
text-xs font-medium transition-all duration-150
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
active
? 'border-krow-blue/40 bg-krow-blue text-white shadow-xs'
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-1 hover:bg-surface-subtle'
)}
>
{group.label}
<span className={cn('text-[11px] tabular-nums font-semibold', active ? 'text-white/85' : 'text-ink-4')}>
{group.count}
</span>
</button>
);
})}
</div>
);
}
/** @param {any} props */
export function SkillCatalog({
entries,
groups,
total,
query,
onQueryChange,
group,
onGroupChange,
attachedIds,
onOpen,
onAddAgent,
onTest,
testableIds,
busy = false,
pendingId = null,
}) {
const searchId = React.useId();
return (
<section aria-label="Skill catalog" className="flex min-w-0 flex-col gap-4">
<div className="space-y-3">
<div>
<label htmlFor={searchId} className="sr-only">Search skills</label>
<SearchInput
id={searchId}
value={query}
onChange={onQueryChange}
placeholder="Search skills, capabilities or pages..."
debounce={0}
/>
</div>
<CategoryFilter groups={groups} value={group} onChange={onGroupChange} total={total} />
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 pt-1">
{/* The count is read from the same array the cards come from, so a
heading cannot claim six above four cards. */}
<p className="text-caption font-medium text-ink-3" aria-live="polite">
<span className="font-bold text-ink-1">{entries.length}</span> of {total} capabilit{total === 1 ? 'y' : 'ies'} for this scope
{attachedIds.size > 0 && <span className="font-semibold text-krow-blue"> · {attachedIds.size} enabled</span>}
</p>
{/* Authoring stays reachable without being a destination of its own. */}
<Link
to="/admin/workspace/skills/owliver/new"
className={`inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5
text-caption font-semibold text-krow-blue shadow-2xs transition-all duration-base
hover:border-krow-blue/40 hover:bg-krow-blue-tint focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> New skill
</Link>
</div>
{entries.length > 0 ? (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,16rem),1fr))] gap-3">
{entries.map((entry) => (
<SkillCard
key={entry.id}
entry={entry}
enabled={attachedIds.has(entry.id)}
onOpen={onOpen}
onAddAgent={onAddAgent}
onTest={onTest}
canTest={testableIds.has(entry.id)}
busy={busy}
isPending={pendingId === entry.id}
/>
))}
</ul>
) : (
<div className="rounded-2xl border border-dashed border-border bg-surface-subtle/50 px-4 py-12 text-center">
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
<p className="mt-2.5 text-body-sm font-semibold text-ink-1">No skills match that</p>
<p className="mt-1 text-caption text-ink-3">
Try a different word, or widen the category filter.
</p>
</div>
)}
</section>
);
}
export default SkillCatalog;

View File

@@ -0,0 +1,346 @@
import * as React from 'react';
import {
AlertCircle, CheckCircle2, LayoutGrid, Loader2, MessageSquare, Minus, Play, Plus, Sparkles,
Wrench,
} from 'lucide-react';
import { Alert, Badge, Button, Drawer } from '@/components/ds';
import { cn } from '@/lib/utils';
import { testTargets } from '@/lib/agents/capabilityTest';
import { SurfaceSelect, supportedSurfaces } from './SurfaceSelect';
import { glyphForGroup } from './glyphs';
/**
* One capability, configured — without leaving the workspace.
*
* A drawer rather than a route. The reader is in the middle of composing an
* agent; sending them to `/skills/<id>` to answer "what does this actually do"
* would unmount the catalog, the tree and their search, and the way back would
* be the browser's Back button.
*
* Everything shown is read from the definition. The capability list is what the
* author wrote under `## Capabilities`, the response shapes are what `owliver:`
* declares, and the tools are the actions the skill names — so a panel can never
* describe behaviour the runtime does not have.
*
* **The conversation is not here.** This panel decides *what to test*: which
* surface, and which of the capability's own questions. Pressing Test hands
* that to the Owliver panel already on the page, and the question and the
* answer appear there, in the one conversation this product has. A chat inside
* a drawer beside a chat would have been a second Owliver wearing a hat.
*
* **Three states are kept apart on purpose.** Choosing a surface changes
* nothing. Testing changes nothing. Only Enable writes — so a reader can open
* six capabilities, try all of them, close the drawer, and leave the agent
* exactly as they found it.
*/
/** A titled group of facts, omitted entirely when there are none. */
function Section({ icon: Icon, title, children, count = undefined }) {
return (
<section className="space-y-1.5">
<h3 className="flex items-center gap-1.5 text-caption font-semibold uppercase tracking-wide text-ink-4">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{title}
{count !== undefined && <span className="font-normal tabular-nums">({count})</span>}
</h3>
{children}
</section>
);
}
/** How the run that was handed to Owliver is going. */
function TestState({ test, entryId }) {
if (!test || test.trace?.skillId !== entryId) return null;
const states = {
queued: { icon: Loader2, spin: true, tone: 'text-ink-3', text: 'Handing the question to Owliver…' },
running: { icon: Loader2, spin: true, tone: 'text-krow-blue', text: 'Owliver is responding…' },
done: { icon: CheckCircle2, spin: false, tone: 'text-success', text: 'Test completed — read the answer in Owliver.' },
error: { icon: AlertCircle, spin: false, tone: 'text-destructive', text: test.error || 'Test failed.' },
};
const state = states[test.state] || states.queued;
const Icon = state.icon;
return (
<p className={cn('flex items-start gap-1.5 text-caption font-medium', state.tone)} role="status">
<Icon className={cn('mt-px h-3.5 w-3.5 shrink-0', state.spin && 'animate-spin')} aria-hidden="true" />
<span className="min-w-0">{state.text}</span>
</p>
);
}
/**
* The drawer's content, as its own component.
*
* Extracted because the Drawer portals its children to `document.body`, which
* puts the things most worth checking — the surface selector, the Board note,
* the test state — out of reach of anything rendering this tree without a real
* DOM. A panel whose contents cannot be asserted is a panel whose contents
* drift.
*
* Stateless. Every choice lives in `SkillDetails`, because the footer's Enable
* button needs the same `surface` this renders, and two copies of that would be
* two answers to "what am I about to enable".
*/
/** @param {any} props */
export function CapabilityBrief({
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
canTest, target, onTest, test, busy = false, showTest = true,
}) {
const { shapes, described } = entry.capabilities;
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="neutral">{entry.groupLabel}</Badge>
{attached
? <Badge variant="success">Enabled for this agent</Badge>
: <Badge variant="neutral">Not enabled</Badge>}
{entry.custom && <Badge variant="info">Custom</Badge>}
</div>
<Section icon={Sparkles} title="Available as">
<SurfaceSelect type={entry.type} value={surface} onChange={onSurfaceChange} name={entry.name} />
</Section>
{/**
* The honest Board note.
*
* `SkillSurface` renders a page section from the page it names and the
* account's switched-off list; it takes no agent. So there is no
* agent-scoped Board runtime, and claiming one would be a claim about a
* page that would not change. What exists is the workspace-level switch,
* which is real — enabling Board here uses it, and this says plainly that
* its reach is the workspace rather than this agent.
*/}
{(surface === 'board' || surface === 'both') && (
<Alert tone="warning" title="Board is switched on workspace-wide">
Enabling Board here uses the real switch every page surface already reads, so the section
genuinely appears — but it appears for the whole workspace, not only for this agent.
There is no per-agent Board runtime yet, and this does not pretend there is one.
</Alert>
)}
{described.length > 0 && (
<Section icon={MessageSquare} title="Capabilities" count={described.length}>
<ul className="space-y-1">
{described.map((line) => (
<li key={line} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
<span className="mt-[0.4375rem] h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
<span className="min-w-0">{line}</span>
</li>
))}
</ul>
</Section>
)}
{shapes.length > 0 && (
<Section icon={MessageSquare} title="Answer shapes" count={shapes.length}>
<div className="flex flex-wrap gap-1.5">
{shapes.map((shape) => (
<span
key={shape}
className="rounded-md border border-border bg-surface-subtle px-2 py-0.5 text-caption capitalize text-ink-3"
>
{shape}
</span>
))}
</div>
</Section>
)}
{/* The capability's own questions, and the choice of which one to try.
One list, not two: these are the same suggestions the panel offers on
the page, so the test asks what the product would ask. */}
{entry.suggestions.length > 0 && (
<Section icon={MessageSquare} title="What you can ask" count={entry.suggestions.length}>
<ul className="space-y-1">
{entry.suggestions.map((s) => {
const value = s.prompt || s.label;
const picked = canTest && question === value;
return (
<li key={s.label}>
<button
type="button"
onClick={() => onQuestionChange(value)}
disabled={!canTest}
aria-pressed={picked}
className={cn(
`w-full rounded-lg border px-2.5 py-1.5 text-left text-body-sm transition-colors
duration-base focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/40 disabled:cursor-default`,
picked
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface-subtle/60 text-ink-2 enabled:hover:border-krow-blue/30'
)}
>
&ldquo;{s.label}&rdquo;
{picked && <span className="sr-only"> — chosen for the test</span>}
</button>
</li>
);
})}
</ul>
</Section>
)}
<Section icon={LayoutGrid} title="Answers on" count={entry.surfaces.length}>
{entry.surfaces.length > 0 ? (
<>
<div className="flex flex-wrap gap-1.5">
{entry.surfaces.map((s) => (
<span
key={s.id}
className="rounded-md border border-border bg-surface-subtle px-2 py-0.5 text-caption text-ink-3"
>
{s.label}
</span>
))}
</div>
<p className="text-caption leading-relaxed text-ink-4">
A skill still only answers on the pages it names. Enabling it cannot widen that —
the agent&apos;s own surfaces narrow it further.
</p>
</>
) : (
<p className="text-body-sm text-ink-3">This skill names no page, so it answers nowhere.</p>
)}
</Section>
{entry.tools.length > 0 && (
<Section icon={Wrench} title="Tools it reaches" count={entry.tools.length}>
<ul className="space-y-1.5">
{entry.tools.map((tool) => (
<li key={tool.name} className="rounded-lg border border-border bg-surface px-2.5 py-2">
<p className="flex items-center gap-1.5 text-body-sm font-medium text-ink-1">
{tool.label}
{!tool.readOnly && (
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-semibold text-warning">
Writes
</span>
)}
</p>
{tool.summary && (
<p className="mt-0.5 text-caption leading-snug text-ink-3">{tool.summary}</p>
)}
</li>
))}
</ul>
</Section>
)}
{/* Test: the button and the outcome. The question and the answer live in
the Owliver panel, which is already on this page. */}
{showTest && (
<Section icon={Play} title="Test">
<p className="text-caption leading-relaxed text-ink-3">
{canTest
? <>Runs in the Owliver panel on this page, against <span className="font-medium text-ink-2">{target.label}</span>. Testing changes nothing — the agent is only updated when you enable it.</>
: surface === 'board'
? 'A Board section draws on a page rather than answering a question, so there is nothing to ask it here.'
: 'This capability answers on no page this agent covers, so there is nothing to test against.'}
</p>
<Button variant="outline" size="sm" onClick={onTest} disabled={!canTest || busy} className="w-full">
<Play className="h-3.5 w-3.5" /> Test in Owliver
</Button>
<TestState test={test} entryId={entry.id} />
</Section>
)}
</div>
);
}
/** @param {any} props */
export function SkillDetails({
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
fields = null, onRunTest = null, test = null,
}) {
/**
* The surface this would run on.
*
* Held here rather than in the agent, because choosing one is not a change to
* the agent until Enable says so. Seeded from the first surface the
* definition supports.
*/
const supported = React.useMemo(() => supportedSurfaces(entry?.type), [entry?.type]);
const [surface, setSurface] = React.useState(() => supported[0] || 'owliver');
const [question, setQuestion] = React.useState('');
/* A different capability is a different set of choices. Reset rather than
carry the last one's surface and question over to it. */
React.useEffect(() => {
setSurface(supported[0] || 'owliver');
setQuestion(entry?.suggestions?.[0]?.prompt || entry?.suggestions?.[0]?.label || entry?.name || '');
}, [entry?.id, entry?.name, entry?.suggestions, supported]);
const targets = React.useMemo(
() => (entry && fields ? testTargets(fields.pages, entry.surfaces.map((s) => s.id)) : []),
[entry, fields]
);
if (!entry) return null;
/* Enabled *on the chosen surface* — so switching the selector re-reads the
state rather than showing one surface's answer under another's label. */
const attached = isEnabledOn(entry.id, surface);
const Glyph = glyphForGroup(entry.group);
/* Owliver is the only surface whose runtime answers a question, so it is the
only one there is anything to ask. See the Board note in the brief. */
const canTest = Boolean(onRunTest && fields && targets.length && surface !== 'board');
const target = targets[0] || null;
/* The same implementation the card's Test button uses — one definition of
what testing means, with the surface and question chosen here. */
const runTest = () => {
if (!canTest) return;
onRunTest({ entry, surface, question });
};
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
title={entry.name}
description={entry.description || undefined}
icon={Glyph}
size="default"
footer={(
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
{/* The one control in this product that changes the agent's skills.
Last, under everything the decision should rest on. */}
<Button
variant={attached ? 'outline' : 'default'}
onClick={() => onToggle(entry.id, surface)}
disabled={busy}
>
{attached
? <><Minus className="h-3.5 w-3.5" /> Disable for this agent</>
: <><Plus className="h-3.5 w-3.5" /> Enable for this agent</>}
</Button>
</>
)}
>
<CapabilityBrief
entry={entry}
attached={attached}
surface={surface}
onSurfaceChange={setSurface}
question={question}
onQuestionChange={setQuestion}
canTest={canTest}
target={target}
onTest={runTest}
test={test}
busy={busy}
showTest={Boolean(onRunTest && fields)}
/>
</Drawer>
);
}
export default SkillDetails;

View File

@@ -0,0 +1,109 @@
import * as React from 'react';
import { Check, Layers, LayoutTemplate, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Which surface this capability should run on.
*
* This used to be a read-out — two rows saying "✓ Owliver / — Board". It is now
* the choice it always described, and the constraint is the important part:
* **what can be picked is derived from the definition, never from a name.** A
* skill's facets say whether it teaches Owliver, draws on a page, or both, and
* an option the definition does not support is offered disabled with the reason
* rather than hidden — a reader deciding between surfaces should be able to see
* that the other one exists and why it is not available here.
*
* `Both` appears only when the definition genuinely supports both. Selecting it
* forks nothing: there is one logical skill, and this is a mode on it.
*/
const OPTIONS = [
{
id: 'owliver',
icon: Sparkles,
label: 'Owliver',
hint: 'Can be asked for in the assistant.',
supported: (type) => type === 'owliver' || type === 'both',
},
{
id: 'board',
icon: LayoutTemplate,
label: 'Board',
hint: 'Draws a section on the page.',
supported: (type) => type === 'board' || type === 'both',
},
{
id: 'both',
icon: Layers,
label: 'Both',
hint: 'Assistant and page.',
supported: (type) => type === 'both',
},
];
/** The surfaces this definition can be run on, in order. */
export const supportedSurfaces = (type) =>
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
/** @param {any} props */
export function SurfaceSelect({ type, value, onChange, name }) {
return (
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
{OPTIONS.map((option) => {
const available = option.supported(type);
const selected = available && value === option.id;
const Icon = option.icon;
return (
<button
key={option.id}
type="button"
role="radio"
aria-checked={selected}
disabled={!available}
onClick={() => available && onChange(option.id)}
className={cn(
`flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left
transition-colors duration-base focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
!available && 'cursor-not-allowed border-dashed border-border bg-surface-subtle/40',
available && !selected && 'border-border bg-surface hover:border-krow-blue/30',
selected && 'border-krow-blue bg-krow-blue-tint'
)}
>
{/* The mark carries the state for anyone not reading colour; the
`aria-checked` above carries it for anyone not reading at all. */}
<span
className={cn(
'mt-px grid h-4 w-4 shrink-0 place-items-center rounded-full border',
selected ? 'border-krow-blue bg-krow-blue text-white' : 'border-border bg-surface'
)}
aria-hidden="true"
>
{selected && <Check className="h-2.5 w-2.5" />}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
'flex items-center gap-1.5 text-body-sm font-medium',
!available ? 'text-ink-4' : selected ? 'text-krow-blue' : 'text-ink-1'
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{option.label}
</span>
<span className="mt-0.5 block text-caption leading-snug text-ink-3">
{available
? option.hint
: `This capability does not declare ${option.id === 'both' ? 'both surfaces' : `a ${option.label} surface`}.`}
</span>
</span>
</button>
);
})}
</div>
);
}
export default SurfaceSelect;

View File

@@ -0,0 +1,23 @@
import { Activity, BarChart3, Briefcase, PenLine, Users, Zap } from 'lucide-react';
/**
* Group → glyph.
*
* A hand-written table, for the same reason `icons.js` is one: a definition
* names a group, and only a group written down here resolves to anything. There
* is no path from a Markdown file to a component nobody chose.
*
* A group nobody anticipated falls back to the generic capability mark rather
* than to no icon at all — a card with an empty square reads as broken, and a
* skill whose author invented a category is not broken.
*/
const GLYPHS = {
analytics: BarChart3,
hiring: Briefcase,
workforce: Users,
operations: Activity,
authoring: PenLine,
};
/** The glyph for a catalog group. Never null. */
export const glyphForGroup = (group) => GLYPHS[String(group || '').trim()] || Zap;

View File

@@ -0,0 +1,83 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useActiveAgent } from './AgentContext';
/**
* Who is answering, in Owliver's own header.
*
* This was an agent *switcher*: the header opened a popover with a search box,
* every registered agent, Browse all and Create agent. That made the panel a
* second place to decide which agent answers, competing with the one place that
* actually configures agents — the Agent Editor — and letting a reader put
* Owliver into a state nothing on the page explained.
*
* It is now a label. The agent is whatever `AgentContext` resolves for the page
* being read, which is the same resolution the runtime already uses to answer,
* so the header cannot disagree with the reply underneath it. Switching belongs
* to Agent Registry → Agents → Configure.
*
* Deliberately **not** a button, and deliberately no chevron: a disclosure
* affordance that discloses nothing is worse than none. Nothing here is
* clickable, so nothing here promises a menu.
*/
/** 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 mark a constrained agent carries.
*
* Kept: it is a fact about the answer that follows — this agent does not cover
* this page and will say so — not a control, and losing it would make a
* constrained reply arrive with no warning.
*/
function ConstrainedTag() {
return (
<span className="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">
Constrained
</span>
);
}
/** @param {any} props */
export function AgentBadge({ page }) {
const { agent, covers } = useActiveAgent();
return (
<>
{agent
? <AgentGlyph agent={agent} />
: <OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />}
<div className="min-w-0 flex-1">
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
Owliver
</h2>
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
{agent && !covers && <ConstrainedTag />}
</div>
</div>
</>
);
}
export default AgentBadge;

View File

@@ -1,234 +0,0 @@
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

@@ -125,6 +125,36 @@ export function AssistantPanelProvider({ role, pathname, children }) {
const [request, setRequest] = React.useState(null);
const requestSeq = React.useRef(0);
/**
* Something the workspace has *told* Owliver, waiting to be said.
*
* `ask` above is the page speaking as the reader — it puts a question in the
* composer's place and a turn runs. This is the other half, and it is a
* different act: attaching a skill to an agent is not a question, and staging
* it as one would put words in the reader's mouth and then answer them.
*
* So a notice appends a single assistant turn stating what changed and what
* can now be asked. It is the same conversation, the same panel and the same
* thread — deliberately not a second chat, a toast queue or a system-message
* lane, because the point of Owliver knowing about a skill is that the next
* thing the reader types can use it.
*
* Held as state for the same reason a request is: the panel is unmounted
* while collapsed, and the notice must still be here when it mounts.
*/
const [notice, setNotice] = React.useState(null);
const noticeSeq = React.useRef(0);
/**
* A capability test in flight: `{ id, trace, state, error }`.
*
* `trace` is what the agent editor was testing — the skill, the agent and the
* surface — so the panel can say so and the drawer can show the outcome. It
* is presentation only: nothing in the runtime reads it, and it is never
* persisted with the thread.
*/
const [test, setTest] = React.useState(null);
const context = React.useMemo(
() => resolveAssistantContext(role, pathname),
[role, pathname]
@@ -174,19 +204,68 @@ export function AssistantPanelProvider({ role, pathname, children }) {
* match. Each call carries its own id, so asking the same question twice is
* two turns rather than one ignored repeat.
*/
const ask = React.useCallback(({ question, positionId = null }) => {
const ask = React.useCallback(({
question, positionId = null, scope = null, capability = null, trace = null,
}) => {
const text = String(question || '').trim();
if (!text) return;
requestSeq.current += 1;
setRequest({ id: requestSeq.current, question: text, positionId });
const id = requestSeq.current;
/* `scope` runs the turn against another page's skills — the agent editor
testing a capability. See `useConversation.send`. Ordinary callers omit
it and the turn is about the page the reader is on. */
setRequest({ id, question: text, positionId, scope, capability, trace });
/**
* A capability test is a turn somebody is *waiting on the outcome of*, and
* the panel is the only thing that knows when a turn has finished. So a
* traced ask opens a small status the caller can watch — queued while the
* panel is still mounting or busy, running once the turn starts, then done
* or error. Untraced asks set nothing and behave exactly as before.
*/
if (trace) setTest({ id, trace, state: 'queued', error: null });
setOpenState(true);
}, [setOpenState]);
/** The panel reporting back on a traced turn. */
const reportTest = React.useCallback((id, state, error = null) => {
setTest((current) => (current?.id === id ? { ...current, state, error } : current));
}, []);
/** Drops the test banner — on a new ordinary question, or when it is dismissed. */
const clearTest = React.useCallback(() => setTest(null), []);
/** Taken by the panel once it has actually asked it. */
const consumeRequest = React.useCallback((id) => {
setRequest((current) => (current?.id === id ? null : current));
}, []);
/**
* States a fact in the conversation, without asking anything.
*
* `followUp` are the chips offered under it — a skill's own suggestions, so
* the next step after attaching one is a question that skill was written to
* answer rather than a blank composer.
*
* Never opens the panel on its own. A reader who collapsed Owliver to get the
* full width does not want it reopened by a checkbox, and the notice is still
* waiting in the thread when they open it again.
*/
const announce = React.useCallback(({ text, followUp = null }) => {
const body = String(text || '').trim();
if (!body) return;
noticeSeq.current += 1;
setNotice({
id: noticeSeq.current,
text: body,
followUp: Array.isArray(followUp) && followUp.length ? followUp : null,
});
}, []);
/** Taken by the panel once it has actually said it. */
const consumeNotice = React.useCallback((id) => {
setNotice((current) => (current?.id === id ? null : current));
}, []);
/* Width is written on every drag frame, so persistence is debounced rather
than hitting sessionStorage sixty times a second. */
const setWidthState = React.useCallback((next) => {
@@ -220,8 +299,14 @@ export function AssistantPanelProvider({ role, pathname, children }) {
ask,
request,
consumeRequest,
announce,
notice,
consumeNotice,
test,
reportTest,
clearTest,
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState,
ask, request, consumeRequest]);
ask, request, consumeRequest, announce, notice, consumeNotice, test, reportTest, clearTest]);
/* 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
@@ -265,5 +350,11 @@ export function useAssistantPanel() {
ask: () => {},
request: null,
consumeRequest: () => {},
announce: () => {},
notice: null,
consumeNotice: () => {},
test: null,
reportTest: () => {},
clearTest: () => {},
};
}

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
@@ -25,7 +25,7 @@ import { useAssistantFacts, useConversation, useCurrentUserName } from './useAss
import { buildIntro, buildPrompts } from './dynamic';
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
import { buildOwliverContext } from '@/lib/agents/context';
import { AgentSwitcher } from './AgentSwitcher';
import { AgentBadge } from './AgentBadge';
import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
@@ -221,7 +221,11 @@ export default function KrowAssistant({
const location = useLocation();
/* A question a page has handed over — see the effect below `runPrompt`. */
const { request: panelRequest, consumeRequest } = useAssistantPanel();
const {
request: panelRequest, consumeRequest,
notice: panelNotice, consumeNotice,
test: panelTest, reportTest, clearTest,
} = useAssistantPanel();
/* The app's own router, not a location assignment: a full page load would
discard the thread and the panel state along with it. */
@@ -447,7 +451,7 @@ export default function KrowAssistant({
);
const {
messages, pending, error, busy, send, stop, reset,
messages, pending, error, busy, send, announce, stop, reset,
history, conversationId, openConversation, forgetConversation,
submitFeedback, feedback,
} = useConversation({
@@ -604,17 +608,18 @@ export default function KrowAssistant({
* bypasses intent routing. A typed question carries none and is routed. Both
* land in the same `send`, so there is a single pipeline to reason about.
*/
const handleMessage = React.useCallback((question, capability = null, positionId = null) => {
const handleMessage = React.useCallback((
question, capability = null, positionId = null, scope = null,
) => {
const text = String(question).trim();
if (!text || busy) return;
setInput('');
/* Asking something while reading History returns to the conversation — the
answer is about to arrive there, and leaving the list up would hide it. */
setView('chat');
send({ question: text, capability, positionId });
send({ question: text, capability, positionId, scope });
}, [busy, send]);
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
/**
* A chip is already a complete question, so it runs on click.
@@ -644,9 +649,54 @@ export default function KrowAssistant({
*/
React.useEffect(() => {
if (!panelRequest || busy) return;
handleMessage(panelRequest.question, null, panelRequest.positionId);
handleMessage(
panelRequest.question, panelRequest.capability ?? null,
panelRequest.positionId, panelRequest.scope,
);
if (panelRequest.trace) reportTest(panelRequest.id, 'running');
consumeRequest(panelRequest.id);
}, [panelRequest, busy, handleMessage, consumeRequest]);
}, [panelRequest, busy, handleMessage, consumeRequest, reportTest]);
/**
* Reporting the end of a traced turn.
*
* The panel is the only place that knows a turn finished, and a reader who
* pressed Test is waiting on exactly that. `busy` falling is the signal;
* `error` decides which outcome it was. Nothing is inferred about the
* *content* of the answer — a capability that answered is a pass, and judging
* whether the answer was any good is the reader's job, which is the whole
* reason the answer is shown to them rather than summarised.
*/
React.useEffect(() => {
if (panelTest?.state !== 'running' || busy) return;
reportTest(panelTest.id, error ? 'error' : 'done', error || null);
}, [panelTest, busy, error, reportTest]);
/* A question the reader types themselves ends the test banner: they have
moved on, and leaving "Testing…" above an unrelated answer would label
something that is no longer happening. */
const submit = React.useCallback((text) => {
if (panelTest) clearTest();
handleMessage(text);
}, [handleMessage, panelTest, clearTest]);
/**
* Something the workspace told Owliver, said in the thread.
*
* Attaching a skill to an agent is the only source today. It is stated rather
* than asked, so it appends a turn instead of running one — see
* `AssistantPanelContext.announce` for why those are different acts.
*
* Deferred while a response streams, for the same reason a request is: a
* notice arriving mid-answer waits for the panel rather than landing between
* a question and its reply. Reading History is left alone — the notice is in
* the live thread when the reader comes back to it.
*/
React.useEffect(() => {
if (!panelNotice || busy) return;
announce({ text: panelNotice.text, followUp: panelNotice.followUp });
consumeNotice(panelNotice.id);
}, [panelNotice, busy, announce, consumeNotice]);
const composer = (
<PromptInput
@@ -672,11 +722,12 @@ 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">
{/* 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} />
{/* The avatar and the two lines beside it — "Owliver" over the page,
and who is answering. It is a label, not a control: choosing an
agent belongs to Agent Registry → Configure, not to this header.
The header element, its geometry and the window controls to the
right are unchanged. */}
<AgentBadge page={context.page} />
{/* Window controls.
@@ -747,6 +798,46 @@ export default function KrowAssistant({
</div>
)}
{/**
* A capability test running through this panel.
*
* One line, in the panel's own vocabulary, so the reader knows the next
* answer is the thing they asked for rather than the page talking to
* itself. Deliberately outside the scrolling body: it describes the turn,
* not a message in it, and nothing about it is persisted with the thread.
*/}
{panelTest && (
<div className="flex shrink-0 items-center gap-2 border-b border-white/60 bg-krow-blue-tint/50 px-3.5 py-1.5">
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
panelTest.state === 'error' ? 'bg-destructive'
: panelTest.state === 'done' ? 'bg-success'
: 'animate-pulse bg-krow-blue'
)}
aria-hidden="true"
/>
<p className="min-w-0 flex-1 truncate text-caption text-ink-2">
<span className="font-medium">
{panelTest.state === 'done' ? 'Tested'
: panelTest.state === 'error' ? 'Test failed' : 'Testing'}
</span>
{': '}
{panelTest.trace.skillName}
{panelTest.trace.agentName ? ` on ${panelTest.trace.agentName}` : ''}
</p>
<button
type="button"
onClick={clearTest}
aria-label="Dismiss test indicator"
className={`shrink-0 rounded p-0.5 text-ink-4 transition-colors hover:text-ink-2
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
</div>
)}
{/* ── Body: the only region that scrolls ─────────────────────────── */}
<div
ref={scrollRef}

View File

@@ -26,7 +26,7 @@ import { buildFacts } from './insights';
import { agentRequest } from '@/lib/agents/runtime';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
import { toSnapshots } from './blocks';
import { doc, text as textBlock, toSnapshots } from './blocks';
/** One provider instance for the app's lifetime. */
const provider = createAssistantProvider();
@@ -397,10 +397,36 @@ export function useConversation({
const messagesRef = React.useRef(messages);
messagesRef.current = messages;
const send = React.useCallback(async ({ question, capability = null, positionId = null }) => {
/**
* Asks something.
*
* `scope` runs this one turn against a *different* page and a different set
* of reachable skills, and exists for exactly one caller: testing a
* capability from the agent editor. The reader is standing on the
* configuration screen, so the panel's own context is the workspace — asking
* "what happened today?" there would resolve against the workspace's skills
* and prove nothing about the Activity capability being configured.
*
* It is an override of *which page this question is about*, never of what may
* be read: `resolveIntent` and the provider apply the same page rules to the
* substituted context that they apply to a real one, so a test cannot reach a
* record the real page would not have offered. The turn lands in the ordinary
* thread, is persisted and archived like any other, and nothing about it is
* simulated — it is the live pipeline pointed at another surface.
*/
const send = React.useCallback(async ({
question, capability = null, positionId = null, scope = null,
}) => {
const text = String(question).trim();
if (!text) return;
/* The page this turn is about, and what is reachable there. Defaults are
the panel's own, so every existing caller is unchanged. */
const turnContext = scope?.contextId || contextId;
const turnDisabled = scope?.disabledSkills || disabledSkills;
const turnAgent = scope?.agent || agent;
const turnCovers = scope ? true : agentCoversPage;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
@@ -419,7 +445,7 @@ export function useConversation({
*/
let intent = null;
if (flowRef.current) {
const skill = skillsForContext(contextId, disabledSkills, customSkills)
const skill = skillsForContext(turnContext, turnDisabled, customSkills)
.find((s) => s.id === flowRef.current.skillId);
if (!skill) setFlow(null);
@@ -438,9 +464,14 @@ export function useConversation({
intent = capability
? { kind: 'answer' }
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories,
courses, workforce, skillContext, positionId,
agent, agentCoversPage, agentSuggestion, owliverContext,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
});
}
@@ -476,7 +507,7 @@ export function useConversation({
followUp: [
...createdFollowUp(created),
...(ready
? suggestionsForPosition(contextId, disabledSkills, customSkills, created)
? suggestionsForPosition(turnContext, turnDisabled, customSkills, created)
: []),
],
};
@@ -664,10 +695,10 @@ export function useConversation({
try {
let latest = [];
for await (const snapshot of provider.stream({
contextId, capability, question: text, facts, signal: controller.signal,
contextId: turnContext, 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,
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
owliverContext,
})) {
if (controller.signal.aborted) break;
@@ -703,10 +734,39 @@ export function useConversation({
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext]);
customSkills, roles, skillCategories, courses, skillContext,
agent, agentCoversPage, agentSuggestion, owliverContext]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);
/**
* States something in the thread without a question having been asked.
*
* The workspace's own voice: "Attendance Analysis is now available to this
* agent." It is a real turn — persisted and archived by the same `persist`
* every answer goes through, so it survives a reload and appears in History
* exactly where it happened, rather than being a banner that evaporates.
*
* Nothing is generated. The caller supplies the sentence and the chips, which
* is what keeps this from being a second answering path: no skill runs, no
* provider is called, and nothing here can claim a figure.
*/
const announce = React.useCallback(({ text: body, followUp = null }) => {
const sentence = String(body || '').trim();
if (!sentence) return;
const message = {
role: 'assistant',
text: sentence,
blocks: doc(textBlock(sentence)).blocks,
...(followUp?.length ? { followUp } : null),
};
const next = [...messagesRef.current, message];
messagesRef.current = next;
persist(next);
}, [persist]);
/**
* Back to an empty panel.
*
@@ -797,6 +857,7 @@ export function useConversation({
error,
busy: Boolean(pending),
send,
announce,
stop,
reset,
submitFeedback,

View File

@@ -262,8 +262,12 @@ export default function AdminLayout() {
<DropdownMenuItem onClick={() => navigate('/admin/profile#security')} className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" /> Security
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/admin/workspace')} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" /> Workspace
{/* Straight to the registry itself. The Workspace overview at
`/admin/workspace` still exists and still reaches the same
list through its own Manage Agents button — it is simply
not a stop on the way there from this menu. */}
<DropdownMenuItem onClick={() => navigate('/admin/workspace/agents')} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" /> Agent Registry
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -312,7 +316,7 @@ export default function AdminLayout() {
{[
{ label: 'Settings', path: '/admin/settings' },
{ label: 'Security', path: '/admin/profile#security' },
{ label: 'Workspace', path: '/admin/workspace' },
{ label: 'Agent Registry', path: '/admin/workspace/agents' },
].map((item) => (
<Link
key={item.label}

View File

@@ -0,0 +1,181 @@
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
import { allSkills, matchSkill, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
import { canonicalPage, surfaceFor } from '@/lib/skills/surfaces';
import { toolsForContext } from '@/lib/skills/tools';
import { agentCovers, agentScopedDisabled } from './runtime';
/**
* Trying a capability before committing to it.
*
* Two things have to be true for this to be worth having, and both are about
* refusing to fake something:
*
* 1. **The evaluation is the live runtime.** `agentCovers`, `skillsForContext`,
* `agentScopedDisabled`, `matchSkill` and `toolsForContext` are the same
* functions the assistant routes every real question through. Nothing here
* re-implements any of them, so a green result cannot mean something
* different from what will happen on the page.
*
* 2. **Testing is not saving.** The agent this reasons about is *hypothetical*
* — the draft's fields with the candidate capability added — and it is
* never written anywhere. A reader can try six capabilities and leave with
* the agent exactly as they found it.
*
* The other half of the test is the answer itself, and that is deliberately not
* here: `scopeFor` returns what `send({ scope })` needs so the question runs in
* the *existing* Owliver panel against the target page. One conversation, one
* pipeline, no second chat.
*/
/** contextId → the page key it stands for, resolved once. */
const CONTEXTS = Object.values(PLACEMENT_ROUTES).map((contextId) => ({
contextId,
pageKey: pageKeyForContext(contextId),
}));
/** The assistant context for a surface, or null if the page carries no panel. */
export function contextForPage(pageKey) {
const wanted = canonicalPage(pageKey) || pageKey;
const hit = CONTEXTS.find((c) => c.pageKey && (canonicalPage(c.pageKey) || c.pageKey) === wanted);
return hit?.contextId || null;
}
/**
* The surfaces a capability can actually be tried on, for this agent.
*
* The intersection of what the agent covers and what the capability declares —
* anywhere else the runtime would decline, and offering it as a test target
* would be offering a test guaranteed to fail for a reason that is not about
* the capability.
*/
export function testTargets(agentPages = [], skillPages = []) {
const skill = new Set((skillPages || []).map((p) => canonicalPage(p) || p));
return (agentPages || [])
.map((p) => canonicalPage(p) || p)
.filter((p) => skill.has(p))
.map((pageKey) => ({
pageKey,
contextId: contextForPage(pageKey),
label: surfaceFor(pageKey)?.label || pageKey,
}))
.filter((t) => t.contextId);
}
/**
* The agent as it *would* be with this capability attached.
*
* Published on purpose: an unpublished draft is refused by the runtime for a
* reason that has nothing to do with the capability being tried, and a test
* that always says "this agent is not published" answers the wrong question.
*/
function hypotheticalAgent(fields, skillId) {
const skills = skillId && !fields.skills.includes(skillId)
? [...fields.skills, skillId]
: fields.skills;
return {
id: fields.id || 'draft',
name: fields.name || 'This agent',
pages: fields.pages,
skills,
subagents: fields.subagents || [],
knowledge: fields.knowledge || [],
starters: fields.starters || [],
reasoning: fields.reasoning,
webSearch: fields.webSearch,
status: 'published',
};
}
/**
* What the runtime would do with this question, on this page, with this
* capability added.
*
* `matched` is the honest headline: it is the skill that would actually answer.
* When that is the capability under test, the test proves the capability;
* when it is a different one, it says so rather than claiming a pass — two
* definitions claiming the same phrase is a real condition and the reader
* should see it here rather than discover it in production.
*/
export function evaluateCapability({
fields, skillId, contextId, question, customSkills = [], entry = null,
}) {
const agent = hypotheticalAgent(fields, skillId);
const registry = allSkills(customSkills);
const disabledSkills = agentScopedDisabled(agent, registry, []);
const covers = agentCovers(agent, contextId);
const reachable = covers ? skillsForContext(contextId, disabledSkills, customSkills) : [];
const offered = reachable.some((s) => s.id === skillId);
/**
* There are two ways a question reaches a skill, and conflating them made
* this panel lie about its own suggestions.
*
* A **typed** question is routed by `matchSkill` against declared triggers. A
* **suggestion** is not routed at all: it is a chip, it carries the capability
* it asks for, and `send` short-circuits intent resolution for exactly that
* reason (`capability ? { kind: 'answer' }`). So "What kinds of event are
* there?" — a suggestion Activity Analysis declares — matches none of its
* triggers and is still answered by it every time.
*
* Reporting that as "no skill claims this wording" was true of the matcher and
* false of the product. Both paths are modelled here, and the one that applies
* is named, so the reader is told *how* it would be answered rather than being
* shown a warning about a question that works.
*/
const declared = (entry?.suggestions || []).find(
(sug) => (sug.prompt || sug.label) === question
);
const matched = covers && question && !declared
? matchSkill(question, contextId, disabledSkills, customSkills)
: null;
return {
agent,
disabledSkills,
covers,
/** Is the capability under test offered at all on this page? */
reachable: offered,
reachableCount: reachable.length,
/** The skill that would answer a *typed* question, if any. */
matched,
/** True when the capability being tested is the one that answers. */
claims: Boolean(offered && (declared || (matched && matched.id === skillId))),
/** How it would be answered: its own suggestion, or a matched trigger. */
route: declared ? 'suggestion' : matched ? 'trigger' : null,
/** The capability a suggestion asks for — passed to `send` as a chip would. */
capability: declared?.capability || null,
tools: covers ? toolsForContext(contextId, disabledSkills, customSkills) : [],
pageLabel: ASSISTANT_CONTEXTS[contextId]?.page || contextId,
};
}
/**
* What `send({ scope })` needs to run this turn against the target page.
*
* Deliberately the same `disabledSkills` the evaluation used, so the answer the
* reader reads is produced under exactly the conditions the diagnostics above
* described.
*/
export const scopeFor = (evaluation, contextId) => ({
contextId,
disabledSkills: evaluation.disabledSkills,
agent: evaluation.agent,
});
/**
* Questions worth trying, taken from the capability itself.
*
* A definition's `owliver.suggestions` are the questions its author wrote it to
* answer, so they are the fairest test of it — and they keep the test from
* being a blank box the reader has to guess at. A definition with none falls
* back to its own name, which is what its default trigger matches.
*/
export function testQuestions(entry) {
const declared = (entry?.suggestions || []).map((s) => s.prompt || s.label).filter(Boolean);
if (declared.length) return declared.slice(0, 3);
return entry?.name ? [entry.name] : [];
}

431
src/lib/skills/catalog.js Normal file
View File

@@ -0,0 +1,431 @@
import { aiAgentSkills, allSkills, getSkillsForPage, skillsWithFacet } from './registry';
import { canonicalPage, placementLabel, sectionTypeLabel, surfaceFor } from './surfaces';
import { describeTool } from './tools';
/**
* The skill catalog — the registry, read as something a person browses.
*
* There is **no second list of skills here**. Every entry is a definition
* `allSkills` already returned, and every field on it is read off that
* definition: what it can be asked for comes from its `owliver.capabilities`,
* what it can do comes from its `actions`, and where it applies comes from its
* `pages`. A catalog that carried its own copy of any of that would eventually
* offer an agent a skill the runtime does not have — which is the exact failure
* `AddSkillsModal` was written to avoid, and this keeps.
*
* What it adds is *grouping*. A definition may declare `category:` and eleven of
* them do; the rest are placed by what they demonstrably are, never by their id.
* That rule matters: the registry is explicit that nothing downstream may test a
* page or a skill by name, so a definition authored tomorrow has to land in a
* group without this file being edited. It does — every step below reads a
* declaration.
*/
/**
* The groups, in the order a reader meets them.
*
* Deliberately Krow's own domains rather than a generic "Core / Development /
* Data" taxonomy: this workspace hires and rosters people, and a category
* called Development would be a heading with nothing under it.
*
* A definition may still declare a `category:` outside this list — the registry
* keeps that field free text on purpose — and `catalogGroups` surfaces it beside
* these rather than dropping it.
*/
export const SKILL_GROUPS = [
{
id: 'analytics',
label: 'Analytics',
blurb: 'The workspace as figures — trends, coverage and the headline picture.',
},
{
id: 'hiring',
label: 'Hiring',
blurb: 'The pipeline: open roles, applicants, and who has already been hired.',
},
{
id: 'workforce',
label: 'Workforce',
blurb: 'The people already on the roster — attendance, hours and training.',
},
{
id: 'operations',
label: 'Operations',
blurb: 'What is happening now, and what is going wrong.',
},
{
id: 'authoring',
label: 'Authoring',
blurb: 'Skills that create a record from the conversation rather than reading one.',
},
];
const GROUP_BY_ID = new Map(SKILL_GROUPS.map((g) => [g.id, g]));
/**
* The group a surface belongs to.
*
* Keyed on the closed surface vocabulary rather than on skill ids, so this is a
* statement about the product's pages — which are a fixed set — and not about
* any particular definition. A skill attaching to a surface listed here inherits
* its group for free.
*/
const GROUP_BY_SURFACE = {
'control-center': 'analytics',
analytics: 'analytics',
positions: 'hiring',
'create-position': 'hiring',
candidates: 'hiring',
'candidates-analysis': 'hiring',
'hired-history': 'hiring',
'talent-pool': 'hiring',
activity: 'operations',
'krow-forge': 'workforce',
profile: 'workforce',
};
/**
* Whether this definition *writes* rather than reads.
*
* Read off two declarations, both of which mean the same thing in different
* words: a `prompt:` is a definition offering to start a piece of work, and an
* action the tool table marks as needing approval is one that changes a record.
* Either makes a skill an authoring skill, and neither is a name.
*/
function isAuthoring(skill) {
if (skill.prompt) return true;
return (skill.actions || []).some((name) => {
const tool = describeTool(name);
return Boolean(tool && (tool.mutates || tool.requiresApproval));
});
}
/**
* Which group a definition belongs to.
*
* Its own `category:` always wins — an author who wrote one has already
* answered this question. Everything after that is inference, in the order of
* how much the definition is actually saying: what it does, then where it
* applies, then nothing.
*/
export function groupFor(skill) {
const declared = String(skill.category || '').trim().toLowerCase();
if (declared) return declared;
if (isAuthoring(skill)) return 'authoring';
for (const page of skill.pages || []) {
const group = GROUP_BY_SURFACE[page];
if (group) return group;
}
return 'general';
}
/** A group's label, whether it is one of ours or one an author invented. */
export const groupLabel = (id) => GROUP_BY_ID.get(id)?.label
|| String(id || '').replace(/[-_]/g, ' ').replace(/^./, (c) => c.toUpperCase());
/**
* What a skill can be asked for, in the reader's words.
*
* The `owliver:` capabilities are the machine-readable half — `summary`,
* `table`, `flow` — and the `## Capabilities` bullets are the sentence the
* author wrote. Both are shown, because one says what shape an answer takes and
* the other says what the answer is about.
*/
function capabilitiesOf(skill) {
const declared = skill.owliver?.capabilities || [];
return {
/** Response shapes this skill offers in the panel. */
shapes: [...declared],
/** The author's own description of what it can do. */
described: skill.capabilities || [],
};
}
/** The tools a skill reaches, described — never a tool it did not declare. */
function toolsOf(skill) {
return (skill.actions || [])
.map((name) => describeTool(name) || { name, label: name, summary: '', readOnly: true })
.filter(Boolean);
}
/** The surfaces a skill answers on, as the product names them. */
function surfacesOf(skill) {
return (skill.pages || []).map((page) => ({
id: page,
label: surfaceFor(page)?.label || page,
}));
}
/**
* One catalog entry: a definition, plus the readings a card and a details panel
* need. Nothing is invented — every field traces back to the parsed skill.
*/
export function catalogEntry(skill) {
const group = groupFor(skill);
return {
id: skill.id,
name: skill.name,
description: skill.description,
group,
groupLabel: groupLabel(group),
/** Owliver, Board, or both — from the definition's own facets. */
type: capabilityType(skill),
capabilities: capabilitiesOf(skill),
tools: toolsOf(skill),
surfaces: surfacesOf(skill),
/** Questions this skill was written to be asked, offered as chips. */
suggestions: (skill.owliver?.suggestions || []).map((s) => ({
label: s.label,
prompt: s.prompt,
capability: s.capability ?? null,
})),
/** How many questions its guided flow asks, when it has one. */
questions: (skill.conversation || []).length,
prompt: skill.prompt || null,
custom: Boolean(skill.custom),
status: skill.status,
};
}
/**
* Every skill an agent may carry, as catalog entries.
*
* The same filter `AddSkillsModal` applied, kept exactly: assistant skills with
* the `owliver` facet and an active status. A workforce training path is
* something a *person* learns — offering one here would promise an agent
* behaviour that does not exist.
*/
export function skillCatalog(customSkills = [], { pages = null } = {}) {
/* `pages: null` means "the whole registry" and is what the skill library
wants. An agent always passes its scope, and gets only what it could
actually use — see `compatibleSkills`. */
const source = pages
? compatibleSkills(pages, customSkills)
: aiAgentSkills(allSkills(customSkills));
return skillsWithFacet(source, 'owliver')
.filter((s) => s.status === 'active')
.map(catalogEntry)
.sort((a, b) => a.name.localeCompare(b.name));
}
/* ── Scope, and what is compatible with it ────────────────────────────────
*
* The fix for the catalog that offered every agent all eighteen skills.
*
* An agent is not a container you may put anything in. It answers on a set of
* surfaces, a skill declares the surfaces it answers on, and the overlap is the
* only set that can ever do anything. Offering Create Position to the Activity
* Agent was not merely noisy — it was offering an attachment the runtime would
* then refuse, because `skillsForContext` filters by page *before* an agent's
* own list is ever consulted. The catalog was advertising attachments that
* could not work.
*
* **One function over declared data, not eight lists.** `getSkillsForPage` is
* the registry's own answer to "what belongs here" and is what the live
* assistant already routes through, so the catalog and the runtime cannot
* disagree by construction. Nothing below tests an agent by name, and a surface
* or a skill added tomorrow is scoped correctly without this file being edited.
*/
/** The surfaces an agent answers on, canonicalized so aliases resolve. */
export const scopeOf = (pages = []) =>
[...new Set(pages.map((p) => canonicalPage(p) || p).filter(Boolean))];
/**
* Every skill compatible with a scope — the union over its surfaces.
*
* Union rather than intersection: an agent covering Positions and Create
* Position may use a skill answering on either, which is exactly what the
* runtime does when the reader is standing on one of them.
*/
export function compatibleSkills(pages = [], customSkills = []) {
const seen = new Map();
for (const page of scopeOf(pages)) {
for (const skill of getSkillsForPage(page, { customSources: customSkills, kind: 'assistant' })) {
if (!seen.has(skill.id)) seen.set(skill.id, skill);
}
}
return [...seen.values()];
}
/** Is this capability usable by an agent with this scope? */
export const isCompatible = (skillId, pages = [], customSkills = []) =>
compatibleSkills(pages, customSkills).some((s) => s.id === skillId);
/**
* Which surfaces a capability offers itself on — Owliver, Board, or both.
*
* Read off the facets the parser already derives from what the definition
* declares. There is deliberately **no new `capabilityType:` field**: a second
* place to say the same thing is a second place for it to be wrong, and a
* definition that gains an `owliver:` block tomorrow becomes `both` on its own.
* One logical capability, two surfaces — never two definitions.
*/
export function capabilityType(skill) {
const owliver = skill.facets?.includes('owliver');
const board = skill.facets?.includes('ui');
if (owliver && board) return 'both';
return board ? 'board' : 'owliver';
}
/** The type, in the words the detail panel uses. */
export const TYPE_LABEL = { owliver: 'Owliver', board: 'Board', both: 'Owliver + Board' };
/* ── Board skills ─────────────────────────────────────────────────────────
*
* The registry's other facet, read the same way. A Board skill draws a section
* on a KROW page; an Owliver skill teaches the assistant what it can be asked.
* One registry, one parser, two readings — exactly the split
* `WorkspaceSkills` already manages, surfaced here so the agent editor can show
* both under one Skills heading without either becoming a second system.
*
* **The relationship is genuinely different, and this module says so rather
* than flattening it.** An Owliver skill is carried by an agent: `agent.skills`
* decides what that agent may use, and `agentScopedDisabled` enforces it. A
* Board skill is not — `SkillSurface` renders sections from the page and the
* account's `disabledSkills`, and consults no agent at all. So a Board entry
* carries the surfaces it draws on and whether it is switched on, and never an
* "attached to this agent" flag, because there is nothing behind one.
*/
/** The sections a Board skill declares, flattened with the page each sits on. */
function sectionsOf(skill) {
return Object.entries(skill.ui || {}).flatMap(([page, config]) =>
(config.sections || []).map((section) => ({
id: section.id,
title: section.title || sectionTypeLabel(section.type),
type: section.type,
typeLabel: sectionTypeLabel(section.type),
page,
pageLabel: surfaceFor(page)?.label || page,
placement: section.placement,
placementLabel: placementLabel(section.placement),
source: section.source || null,
}))
);
}
/** One Board skill, as the agent editor needs to read it. */
export function boardEntry(skill) {
const group = groupFor(skill);
const sections = sectionsOf(skill);
return {
id: skill.id,
name: skill.name,
description: skill.description,
group,
groupLabel: groupLabel(group),
type: capabilityType(skill),
sections,
/** The pages this skill actually draws on, derived from its sections. */
surfaces: [...new Map(sections.map((s) => [s.page, { id: s.page, label: s.pageLabel }])).values()],
custom: Boolean(skill.custom),
status: skill.status,
};
}
/** Every Board skill in the registry, as entries. */
export function boardCatalog(customSkills = [], { pages = null } = {}) {
const source = pages
? compatibleSkills(pages, customSkills)
: aiAgentSkills(allSkills(customSkills));
return skillsWithFacet(source, 'ui')
.filter((s) => s.status === 'active')
.map(boardEntry)
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Does this Board skill draw on any surface the agent covers?
*
* The true relationship between an agent and a Board skill, and the only one
* there is: they can meet on a page. An agent that answers on Positions stands
* beside whatever Board sections Positions renders — it does not own them, and
* it cannot switch them on.
*/
export const boardMeetsAgent = (entry, pages = []) => {
if (!pages.length) return false;
const covered = new Set(pages);
return entry.surfaces.some((s) => covered.has(s.id));
};
/** Board entries, filtered by the same query the Owliver catalog uses. */
export function filterBoard(entries, { query = '' } = {}) {
const q = String(query || '').trim().toLowerCase();
if (!q) return entries;
return entries.filter((e) => [
e.name, e.description, e.groupLabel, e.id,
...e.surfaces.map((s) => s.label),
...e.sections.map((s) => `${s.title} ${s.typeLabel} ${s.placementLabel}`),
].join(' ').toLowerCase().includes(q));
}
/**
* The groups this catalog actually contains, in a stable order.
*
* Derived rather than written down, so a filter can never offer a heading that
* matches nothing — and a definition declaring a category nobody anticipated
* appears under it instead of vanishing into "everything else".
*/
export function catalogGroups(entries = []) {
const counts = new Map();
for (const entry of entries) {
counts.set(entry.group, (counts.get(entry.group) || 0) + 1);
}
const known = SKILL_GROUPS
.filter((g) => counts.has(g.id))
.map((g) => ({ ...g, count: counts.get(g.id) }));
const extra = [...counts.keys()]
.filter((id) => !GROUP_BY_ID.has(id))
.sort()
.map((id) => ({ id, label: groupLabel(id), blurb: '', count: counts.get(id) }));
return [...known, ...extra];
}
/**
* Does this entry match what was typed?
*
* Name, description, group, the surfaces it answers on, the capabilities it
* offers and its id — so "attendance", "Control Center", "table" and
* "anomaly-detection" all find something, and a reader who knows the domain
* rather than the catalog can still search it.
*/
export function matchesQuery(entry, query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return true;
const haystack = [
entry.name,
entry.description,
entry.groupLabel,
entry.id,
...entry.surfaces.map((s) => s.label),
...entry.capabilities.shapes,
...entry.capabilities.described,
...entry.tools.map((t) => t.label),
].join(' ').toLowerCase();
return haystack.includes(q);
}
/**
* The catalog, filtered.
*
* One function so the count shown beside a filter and the cards under it are
* the same reading — two filters written separately is how a heading comes to
* say "6 skills" above four cards.
*/
export function filterCatalog(entries, { query = '', group = 'all' } = {}) {
return entries
.filter((e) => group === 'all' || e.group === group)
.filter((e) => matchesQuery(e, query));
}

View File

@@ -735,7 +735,7 @@ export function contextIdsForSkill(skill) {
* Nothing downstream may test a page name against a skill id. The moment a page
* asks "is this Server Training?" the attachment has stopped being data.
*/
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind = undefined } = {}) {
if (!pageId) return [];
const wanted = canonicalPage(pageId) || pageId;
return allSkills(customSources).filter(

View File

@@ -250,6 +250,22 @@ for (const surface of SKILL_SURFACES) {
}
/** Every name a definition may use for a surface, for error messages. */
/**
* The surfaces that are product *domains* rather than configuration screens.
*
* Read off `placements`, which is already the honest distinction: a domain
* surface offers places for a section to sit because it holds workforce
* records, and a configuration screen offers none because it holds none. That
* is why Settings, Workspace and the two editors declare `placements: []`.
*
* The agent editor uses this so its scope picker offers the pages an agent
* could sensibly answer *about*, instead of every route the vocabulary happens
* to name. Derived, so a surface added tomorrow classifies itself.
*/
export const DOMAIN_SURFACES = SKILL_SURFACES
.filter((s) => s.placements.length > 0)
.map((s) => s.id);
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
/**

View File

@@ -7,14 +7,18 @@ import {
import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { usePreferences } from '@/lib/krowHooks';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { reportSave } from '@/lib/skills/saveFeedback';
import { useAgents } from '@/lib/agents/useAgents';
import { agentTemplate } from '@/lib/agents/customAgents';
import { agentFieldsFromSource, applyAgentFields } from '@/lib/agents/agentFields';
import { validateAgentSource } from '@/lib/agents/registry';
import { AgentConfigure } from '@/components/agents/AgentConfigure';
import { AgentSkillWorkspace } from '@/components/agents/skills/AgentSkillWorkspace';
import { AgentTestPanel } from '@/components/agents/AgentTestPanel';
import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
import { useAssistantPanel } from '@/components/ai-assistant';
import { boardCatalog, skillCatalog } from '@/lib/skills/catalog';
/**
* One agent, configured.
@@ -31,6 +35,44 @@ import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
const STATUS_TONE = { published: 'success', draft: 'neutral', archived: 'warning' };
/**
* What Owliver says when a skill joins or leaves an agent.
*
* Composed from the definition itself — its name, its own description, and the
* questions it declares — so the sentence cannot promise a capability the skill
* does not have. Nothing is generated: this is the workspace stating a fact in
* the conversation, and the chips under it are the skill's own suggestions, so
* the next step is a question that skill was written to answer.
*/
function skillNotice({ entry, id, agentName, attached }) {
const name = entry?.name || id;
const on = agentName ? `**${agentName}**` : 'this agent';
if (!attached) {
return {
text: `**${name}** has been removed from ${on}. It no longer answers here.`,
followUp: null,
};
}
const opening = `**${name}** is now available to ${on}.`;
const what = entry?.description ? ` ${entry.description}` : '';
const invitation = entry?.suggestions?.length
? ' Ask me one of these, or anything else it covers.'
: ' Ask me about it whenever you need it.';
return {
text: `${opening}${what}${invitation}`,
followUp: entry?.suggestions?.length
? entry.suggestions.slice(0, 3).map((s) => ({
label: s.label,
prompt: s.prompt,
...(s.capability ? { capability: s.capability } : null),
}))
: null,
};
}
export default function AdminAgentDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -53,6 +95,50 @@ export default function AdminAgentDetail() {
const [fields, setFields] = useState(() => (baseSource ? agentFieldsFromSource(baseSource) : null));
const [dirty, setDirty] = useState(false);
const [view, setView] = useState('configure');
/* The skill currently being written, so its card can say so and a second
click cannot race the first. */
const [pendingSkill, setPendingSkill] = useState(/** @type {string|null} */ (null));
/* The same catalog the workspace renders, read here so the sentence Owliver
says about a skill comes from the definition rather than from the card. */
const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
const catalog = useMemo(() => skillCatalog(customSkills), [customSkills]);
/* Owliver's existing panel, on this page already. Attaching a skill states a
fact in that conversation — it never opens a second one. */
const { announce, ask, test } = useAssistantPanel();
/**
* Trying a capability, in the panel already on this page.
*
* `scope` runs the turn against the surface the capability actually answers
* on — see `useConversation.send`. Nothing is written: this is the whole of
* "test", and the agent is unchanged until Enable is pressed.
*/
const onTestCapability = useCallback(({ question, scope, capability, trace }) => {
ask({ question, scope, capability, trace });
}, [ask]);
/* Board skills are governed by the account's `disabledSkills` — the one list
`SkillSurface` actually reads. Written through the same `updatePreferences`
the Skills page uses, so there is no second persistence for it. */
const updatePreferences = useUpdatePreferences();
const disabledSkills = useMemo(
() => preferences.disabledSkills || [],
[preferences.disabledSkills]
);
const onToggleBoardSkill = useCallback((skillId, enabled) => {
const entry = boardCatalog(customSkills).find((e) => e.id === skillId);
const next = enabled
? disabledSkills.filter((x) => x !== skillId)
: [...new Set([...disabledSkills, skillId])];
updatePreferences.mutate(
{ disabledSkills: next },
reportSave(`${entry?.name || skillId} ${enabled ? 'enabled' : 'disabled'}`)
);
}, [customSkills, disabledSkills, updatePreferences]);
/* Reload when the address changes, but never overwrite an edit in progress. */
useEffect(() => {
@@ -86,6 +172,82 @@ export default function AdminAgentDetail() {
return result.agent;
}, [fields, baseSource, save, creating, navigate]);
/**
* Attaching and detaching a skill.
*
* The one write path for `fields.skills`, and deliberately not the same one
* the rest of the form uses. Every other field is a *draft* until Save — a
* half-typed name should never reach Owliver. A skill is not like that: the
* whole point of the catalog is that the agent gains the capability there and
* then, and the tree, the card and Owliver all say so immediately.
*
* So it is optimistic, through the existing `save`, with one honest exception
* and one honest revert:
*
* - **A draft the author is still editing is left a draft.** With unsaved
* changes on screen — or a brand-new agent with no definition yet — a
* write here would silently persist a half-finished name along with the
* skill. The change joins the draft instead, and the header already says
* the agent has unsaved changes.
* - **A refused write is put back.** The list is restored to exactly what it
* was and the reason is shown, rather than leaving a tree claiming a skill
* the stored definition does not carry.
*/
const onToggleSkill = useCallback(async (skillId) => {
if (!fields || pendingSkill) return;
const attached = !fields.skills.includes(skillId);
const previous = fields.skills;
const nextSkills = attached
/* Never a duplicate: the id is added only when it is absent, so clicking
Add twice cannot list the same skill twice. */
? [...previous, skillId]
: previous.filter((x) => x !== skillId);
const nextFields = { ...fields, skills: nextSkills };
const notice = skillNotice({
entry: catalog.find((e) => e.id === skillId) || null,
id: skillId,
agentName: fields.name,
attached,
});
/* A draft stays a draft — see above. */
if (creating || dirty || !agent) {
setFields(nextFields);
setDirty(true);
announce(notice);
return;
}
setFields(nextFields);
setPendingSkill(skillId);
const composed = applyAgentFields(baseSource, nextFields);
const problem = validateAgentSource(composed);
if (problem) {
setFields((current) => ({ ...current, skills: previous }));
setPendingSkill(null);
toast.error(problem);
return;
}
const skillName = catalog.find((e) => e.id === skillId)?.name || skillId;
const result = await save(composed, {
message: attached ? `${skillName} attached` : `${skillName} removed`,
});
setPendingSkill(null);
if (!result.ok) {
setFields((current) => ({ ...current, skills: previous }));
toast.error(result.error);
return;
}
announce(notice);
}, [fields, pendingSkill, creating, dirty, agent, catalog, baseSource, save, announce]);
const onPublish = useCallback(async () => {
const stored = dirty ? await persist() : agent;
if (!stored) return;
@@ -230,7 +392,9 @@ export default function AdminAgentDetail() {
)}
<Tabs
className="mt-3 mb-1"
/* Four tabs do not fit a 320px screen and the tablist does not wrap,
so the strip scrolls rather than the page. */
className="mt-3 mb-1 -mx-0.5 flex-nowrap overflow-x-auto px-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="underline"
size="sm"
ariaLabel="Agent views"
@@ -238,6 +402,11 @@ export default function AdminAgentDetail() {
onChange={setView}
tabs={[
{ value: 'configure', label: 'Configure' },
/* Skills earn their own view rather than a section inside
Configure: composing an agent is a workspace — tree, catalog and
Owliver at once — and it cannot be that inside a column of form
fields. */
{ value: 'skills', label: 'Skills', count: fields.skills.length },
{ value: 'test', label: 'Test' },
{ value: 'insights', label: 'Insights' },
]}
@@ -248,15 +417,31 @@ export default function AdminAgentDetail() {
<AgentConfigure
fields={fields}
agents={agents}
customSkills={preferences.customSkills || []}
customSkills={customSkills}
onChange={onChange}
onOpenSkills={() => setView('skills')}
scopeLocked={shipped}
/>
)}
{view === 'skills' && (
<AgentSkillWorkspace
fields={fields}
customSkills={customSkills}
onToggleSkill={onToggleSkill}
disabledSkills={disabledSkills}
onToggleBoardSkill={onToggleBoardSkill}
onTestCapability={onTestCapability}
test={test}
busy={saving || Boolean(pendingSkill)}
pendingId={pendingSkill}
dirty={dirty || creating}
/>
)}
{view === 'test' && (
<AgentTestPanel
fields={fields}
dirty={dirty}
customSkills={preferences.customSkills || []}
customSkills={customSkills}
/>
)}
{view === 'insights' && (

View File

@@ -198,7 +198,7 @@ export default function AdminSkillDevelopment() {
actions={
<>
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
<ChevronLeft aria-hidden="true" /> Workspace
<ChevronLeft aria-hidden="true" /> Agent Registry
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/university')}>
Manage training

View File

@@ -1,35 +1,39 @@
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
LayoutTemplate, Sparkles,
BookOpen, ArrowRight
} from 'lucide-react';
import { ArrowRight, BookOpen, Sparkles } from 'lucide-react';
import { usePreferences } from '@/lib/krowHooks';
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { aiAgentSkills, allSkills } from '@/lib/skills/registry';
import { allAgents } from '@/lib/agents/registry';
import { AdminPage } from '@/components/admin/PageShell';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { cn } from '@/lib/utils';
/**
* Workspace & Skills — AI agent capability governance.
* Workspace — the way in to AI configuration, and only that.
*
* This page governs what *Owliver* can do. It used to govern workforce training
* as well — Training Paths and Skill Progression sat in the right-hand column —
* and that conflated two unrelated things under one word. A "skill" here is a
* capability the assistant gains; a "skill" in KROW Forge is something a person
* learns, proves and is verified in. They have different owners, different
* lifecycles and different audiences, and putting them side by side made the page
* read as though enabling a Bartending path taught Owliver to tend bar.
* This page used to offer three destinations side by side: Owliver Agents,
* Owliver Skills, and Board Skills. Each was a real thing with a real page
* behind it, and together they asked the reader a question the product should
* never have asked — *which of these three am I supposed to open?* Nothing on
* screen answered it, because the answer is structural: skills are configured
* **on an agent**, and an agent is the only thing here anyone actually manages.
*
* Workforce training was not removed, only returned to where it belongs: the
* definitions, the routes and the pages are untouched, and KROW Forge
* (`/admin/university`) and Skill Development still read them. See
* `workforceTrainingSkills` in the registry for the other half of the split.
* So there is one destination now. Agents. Everything else is reached through
* the agent it belongs to:
*
* Workspace → Agents → Configure → Skills → Owliver | Board
*
* Nothing was deleted to achieve that. `/admin/workspace/skills` still exists
* and still authors definitions — it is where the agent editor's own links go —
* it simply stopped being advertised as a peer of the thing that uses it.
*
* The counts are the same registry reads as before, minus the one that named an
* implementation detail: "Skill Kinds" told a recruiter that the product has two
* internal facets, which is true and is not their problem.
*/
export default function AdminWorkspace() {
const preferences = usePreferences();
/* Read through the agent registry, never by counting skills — one registry
answers "how many agents", exactly as the skill registry answers its own. */
const agentCount = useMemo(
@@ -53,220 +57,112 @@ export default function AdminWorkspace() {
const disabledSkills = preferences.disabledSkills || [];
const activeSkills = skills.filter((s) => !disabledSkills.includes(s.id)).length;
/* The two kinds of agent skill — the same two lists the management page is
split into, read here from the same filtered registry so the landing page and
the manager can never disagree about what is registered. */
const owliverSkills = useMemo(() => skillsWithFacet(skills, 'owliver'), [skills]);
const boardSkills = useMemo(() => skillsWithFacet(skills, 'ui'), [skills]);
const owliverCount = owliverSkills.length;
const boardCount = boardSkills.length;
const metrics = [
{ label: 'Agents', value: agentCount },
{ label: 'Skills', value: skills.length },
{ label: 'Active', value: activeSkills },
];
return (
<AdminPage
title="Workspace"
subtitle="Govern the AI agent capabilities that extend Owliver and KROW pages."
title="Agent Registry"
subtitle="Manage how Owliver behaves across Krow."
>
<div className="space-y-6 w-full">
{/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#0838E0] via-[#1E40AF] to-[#0284C7] p-6 sm:p-8 text-white shadow-xl w-full">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-white/15 via-transparent to-transparent pointer-events-none" />
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/20 backdrop-blur-md text-white text-[12px] font-semibold border border-white/30">
<Sparkles className="w-3.5 h-3.5 text-sky-300" />
AI Capability Governance
<div className="w-full space-y-6">
{/* ── Hero ───────────────────────────────────────────────────────
The same banner, saying three plain numbers instead of two figures
and a word only this codebase uses. */}
<div className="relative w-full overflow-hidden rounded-3xl bg-gradient-to-r from-[#0838E0] via-[#1E40AF] to-[#0284C7] p-6 text-white shadow-xl sm:p-8">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-white/15 via-transparent to-transparent" />
<div className="relative z-10 flex flex-col justify-between gap-6 md:flex-row md:items-center">
<div className="min-w-0 space-y-2">
<div className="inline-flex items-center gap-2 rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[12px] font-semibold text-white backdrop-blur-md">
<Sparkles className="h-3.5 w-3.5 text-sky-300" />
AI Agent Registry
</div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">AI Agent Capabilities</h1>
<p className="text-[14px] text-blue-100 font-normal max-w-2xl">
Govern what Owliver can understand, analyze and execute. Workforce training
paths and talent progression are managed in KROW Forge.
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
AI Agent Registry
</h1>
<p className="max-w-2xl text-[14px] font-normal text-blue-100">
Manage the agents that power Owliver across Krow. Each agent decides which
pages it answers on and which skills it may use.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap sm:shrink-0">
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Registered Skills</p>
<p className="text-2xl font-bold text-white mt-0.5">{skills.length}</p>
<span className="text-[10px] text-emerald-300 font-medium">{activeSkills} Active</span>
</div>
{/* Was "Training Paths". Replaced rather than dropped: the slot is
useful, and the split of agent skills across the two lists is
what this page actually governs. */}
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Skill Kinds</p>
<p className="text-2xl font-bold text-sky-300 mt-0.5">{owliverCount + boardCount}</p>
<span className="text-[10px] text-blue-200">{owliverCount} Owliver · {boardCount} Board</span>
</div>
{metrics.map((metric) => (
<div
key={metric.label}
className="min-w-[100px] flex-1 rounded-2xl border border-white/15 bg-white/10 p-4 text-center backdrop-blur-md sm:flex-none"
>
<p className="text-[11px] font-semibold uppercase tracking-wider text-blue-200">
{metric.label}
</p>
<p className="mt-0.5 text-2xl font-bold tabular-nums text-white">{metric.value}</p>
</div>
))}
</div>
</div>
</div>
{/* ── Agents ─────────────────────────────────────────────────────
Above the two skill columns because it is the layer over them: an
agent decides which of these skills Owliver may use on a page. */}
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<OwliverAvatar className="w-6 h-6" rounded="rounded-md" />
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Agents</h2>
{/* ── The one destination ────────────────────────────────────────
Skills are not a card beside this one. They are configured on an
agent, and this is the door to the agents. */}
<div className="space-y-4 rounded-2xl border border-[#E0E4EA] bg-white p-6 shadow-xs">
<div className="flex items-center justify-between gap-3 border-b border-[#E0E4EA] pb-3">
<div className="flex min-w-0 items-center gap-2">
<OwliverAvatar className="h-6 w-6" rounded="rounded-md" />
<h2 className="truncate text-[16px] font-bold text-[#101820]">Agents</h2>
</div>
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
<span className="shrink-0 rounded-full border border-blue-200/60 bg-blue-50 px-3 py-1 text-[12px] font-semibold text-[#0838E0]">
{agentCount} Registered
</span>
</div>
<p className="text-[13px] text-[#687386]">
An agent is how Owliver answers on a page: which skills it may use, what it knows, and
how much work an answer is worth. Agents narrow what a page offers — they never widen it.
An agent is how Owliver answers on a page: which skills it may use, what it knows,
and how much work an answer is worth. Agents narrow what a page offers — they never
widen it.
</p>
{/* The hierarchy, said once, so the reader knows where skills live
before they click rather than after. */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/60 px-3.5 py-2.5 text-[12px] text-[#687386]">
<span className="font-semibold text-[#101820]">Agents</span>
<ArrowRight className="h-3 w-3 shrink-0" aria-hidden="true" />
<span>Configure</span>
<ArrowRight className="h-3 w-3 shrink-0" aria-hidden="true" />
<span>Skills</span>
<ArrowRight className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="font-medium text-[#101820]">Owliver Skills</span>
<span aria-hidden="true">/</span>
<span className="font-medium text-[#101820]">Board Skills</span>
</div>
<Link
to="/admin/workspace/agents"
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.01]"
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-[#0838E0] px-5 py-3 text-[13px] font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:scale-[1.01] hover:bg-[#062BAF]"
>
<Sparkles className="w-4 h-4" />
<Sparkles className="h-4 w-4" />
Manage Agents
<ArrowRight className="w-4 h-4" />
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{/* ── 2. Full Width 2-Column Grid ───────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
{/* ── Left Column: Owliver AI Capabilities ───────────────────── */}
<div className="space-y-6">
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<OwliverAvatar className="w-6 h-6" rounded="rounded-md" />
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Skills</h2>
</div>
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
{owliverCount} Registered
</span>
</div>
<p className="text-[13px] text-[#687386]">
Owliver skills teach the assistant what it can be asked on a page and how to answer —
screening, matching and contextual analysis. They define what the AI understands, not
what a candidate learns.
</p>
{/* Skills Sample List */}
<div className="space-y-3">
{owliverSkills.slice(0, 4).map((s) => {
const isActive = !disabledSkills.includes(s.id);
return (
<div key={s.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[13px] font-bold text-[#101820]">{s.name || s.id}</p>
{s.description && <p className="text-[12px] text-[#687386] truncate mt-0.5">{s.description}</p>}
</div>
<span className={cn(
'px-2.5 py-0.5 rounded-full text-[11px] font-semibold shrink-0',
isActive ? 'bg-emerald-50 text-[#16A765] border border-emerald-200' : 'bg-gray-100 text-gray-500'
)}>
{isActive ? 'Active' : 'Disabled'}
</span>
</div>
);
})}
</div>
{/* CTA Button */}
<div className="pt-2">
<Link
to="/admin/workspace/skills?tab=owliver"
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.01]"
>
<Sparkles className="w-4 h-4" />
Manage Owliver Skills
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
{/* ── Right Column: Board Skills ─────────────────────────────────
The other kind of AI agent skill. This column previously held
Workforce Training Paths and Skill Progression Tracking, both of
which are KROW Forge concerns — see the note at the top of the file.
Board skills belong here because, like Owliver skills, they are
capabilities the product gains rather than something a person
learns. */}
<div className="space-y-6">
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<LayoutTemplate className="w-5 h-5 text-[#0838E0]" />
<h2 className="text-[16px] font-bold text-[#101820]">Board Skills</h2>
</div>
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
{boardCount} Registered
</span>
</div>
<p className="text-[13px] text-[#687386]">
Board skills add dynamic sections to KROW pages, read from real page data. They
extend what a page shows, not what a candidate learns.
</p>
{/* Sample list, from the same filtered registry read as the left
column — so the two columns can never disagree about what is
registered. */}
<div className="space-y-3">
{boardSkills.slice(0, 4).map((s) => {
const isActive = !disabledSkills.includes(s.id);
return (
<div key={s.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[13px] font-bold text-[#101820]">{s.name || s.id}</p>
{s.description && <p className="text-[12px] text-[#687386] truncate mt-0.5">{s.description}</p>}
</div>
<span className={cn(
'px-2.5 py-0.5 rounded-full text-[11px] font-semibold shrink-0',
isActive ? 'bg-emerald-50 text-[#16A765] border border-emerald-200' : 'bg-gray-100 text-gray-500'
)}>
{isActive ? 'Active' : 'Disabled'}
</span>
</div>
);
})}
{boardSkills.length === 0 && (
<p className="text-[13px] text-[#687386] py-2">
No Board skills registered yet. A Board skill declares a section, where it
sits and what it reads.
</p>
)}
</div>
<div className="pt-2">
<Link
to="/admin/workspace/skills?tab=ui"
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
>
<LayoutTemplate className="w-4 h-4 text-[#0838E0]" />
Manage Board Skills
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
</div>
{/* ── 3. Unified Registry Architecture Banner ───────────────────── */}
<div className="flex items-start gap-3.5 p-5 rounded-2xl bg-blue-50/60 border border-blue-200/50 text-[#0838E0] text-[13px] leading-relaxed">
<BookOpen className="w-5 h-5 shrink-0 text-[#0838E0] mt-0.5" />
<div>
{/* ── The one distinction worth keeping ──────────────────────────
Not a third destination: a standing fact about a word that means two
unrelated things in this product, and the reason KROW Forge is not
on this page. */}
<div className="flex items-start gap-3.5 rounded-2xl border border-blue-200/50 bg-blue-50/60 p-5 text-[13px] leading-relaxed text-[#0838E0]">
<BookOpen className="mt-0.5 h-5 w-5 shrink-0 text-[#0838E0]" />
<div className="min-w-0">
<p className="font-bold text-[#101820]">AI skills and workforce training are different things</p>
<p className="text-[#687386] mt-0.5">
<p className="mt-0.5 text-[#687386]">
This page governs <span className="font-semibold">AI agent skills</span> — what Owliver
understands and what a Board skill draws on a page. <span className="font-semibold">Workforce
training</span> — training paths, proving challenges, skill verification and talent
understands, and what a Board skill draws on a page. <span className="font-semibold">Workforce
training</span> — training paths, proving challenges, verification and talent
progression — is what a person learns, and it is managed in{' '}
<Link to="/admin/university" className="font-semibold underline hover:no-underline">KROW Forge</Link>.
Both are Markdown in one registry, read through separate queries so neither appears in the
other’s surface.
</p>
</div>
</div>

View File

@@ -469,7 +469,7 @@ export default function AdminWorkspaceAgents() {
className="hidden md:inline-flex items-center gap-1.5 rounded-lg border border-border/70 bg-surface px-3 py-1.5 text-body-sm text-ink-3 transition-colors hover:border-krow-blue/40 hover:text-krow-blue hover:bg-surface-subtle"
>
<span>Skills managed in</span>
<span className="font-semibold text-krow-blue">Workspace &rarr; Skills</span>
<span className="font-semibold text-krow-blue">Agent Registry &rarr; Skills</span>
</Link>
<SegmentedToggle

View File

@@ -504,7 +504,7 @@ export default function WorkspaceSkills() {
actions={
<>
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
<ChevronLeft aria-hidden="true" /> Workspace
<ChevronLeft aria-hidden="true" /> Agent Registry
</Button>
{/* Account definitions live in this browser's storage and nowhere
else. Import and export are what make that a place rather than a