From 6d8b4dbf36761f946cc799fd914c3b4d67599734 Mon Sep 17 00:00:00 2001
From: Aravind
Date: Mon, 24 Aug 2026 20:06:11 +0530
Subject: [PATCH] update owliver agent
---
.env.example | 4 +-
.vite-preview.config.mjs | 12 +
src/components/agents/AddSkillsModal.jsx | 180 --------
src/components/agents/AgentConfigure.jsx | 125 +++--
.../agents/skills/AgentSkillWorkspace.jsx | 434 ++++++++++++++++++
src/components/agents/skills/AgentTree.jsx | 329 +++++++++++++
.../agents/skills/BoardSkillList.jsx | 227 +++++++++
src/components/agents/skills/SkillCard.jsx | 177 +++++++
src/components/agents/skills/SkillCatalog.jsx | 148 ++++++
src/components/agents/skills/SkillDetails.jsx | 346 ++++++++++++++
.../agents/skills/SurfaceSelect.jsx | 109 +++++
src/components/agents/skills/glyphs.js | 23 +
src/components/ai-assistant/AgentBadge.jsx | 83 ++++
src/components/ai-assistant/AgentSwitcher.jsx | 234 ----------
.../ai-assistant/AssistantPanelContext.jsx | 97 +++-
src/components/ai-assistant/KrowAssistant.jsx | 119 ++++-
src/components/ai-assistant/useAssistant.js | 79 +++-
src/layouts/AdminLayout.jsx | 10 +-
src/lib/agents/capabilityTest.js | 181 ++++++++
src/lib/skills/catalog.js | 431 +++++++++++++++++
src/lib/skills/registry.js | 2 +-
src/lib/skills/surfaces.js | 16 +
src/pages/admin/AgentDetail.jsx | 193 +++++++-
src/pages/admin/SkillDevelopment.jsx | 2 +-
src/pages/admin/Workspace.jsx | 296 ++++--------
src/pages/admin/WorkspaceAgents.jsx | 2 +-
src/pages/admin/WorkspaceSkills.jsx | 2 +-
vite.config.js | 7 +-
28 files changed, 3176 insertions(+), 692 deletions(-)
create mode 100644 .vite-preview.config.mjs
delete mode 100644 src/components/agents/AddSkillsModal.jsx
create mode 100644 src/components/agents/skills/AgentSkillWorkspace.jsx
create mode 100644 src/components/agents/skills/AgentTree.jsx
create mode 100644 src/components/agents/skills/BoardSkillList.jsx
create mode 100644 src/components/agents/skills/SkillCard.jsx
create mode 100644 src/components/agents/skills/SkillCatalog.jsx
create mode 100644 src/components/agents/skills/SkillDetails.jsx
create mode 100644 src/components/agents/skills/SurfaceSelect.jsx
create mode 100644 src/components/agents/skills/glyphs.js
create mode 100644 src/components/ai-assistant/AgentBadge.jsx
delete mode 100644 src/components/ai-assistant/AgentSwitcher.jsx
create mode 100644 src/lib/agents/capabilityTest.js
create mode 100644 src/lib/skills/catalog.js
diff --git a/.env.example b/.env.example
index 5575a35..c9f37d5 100644
--- a/.env.example
+++ b/.env.example
@@ -37,5 +37,5 @@
VITE_API_BASE_URL=/api/v1
# Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by
-# client code. Change this if the Go API is not on its default address.
-# VITE_API_PROXY_TARGET=http://127.0.0.1:8080
+# client code.
+VITE_API_PROXY_TARGET=https://mcp.korwfoce.com
diff --git a/.vite-preview.config.mjs b/.vite-preview.config.mjs
new file mode 100644
index 0000000..55aa2c5
--- /dev/null
+++ b/.vite-preview.config.mjs
@@ -0,0 +1,12 @@
+// Temporary: `vite preview` with the same /api proxy the dev server uses, so
+// the built bundle can be QA'd against the live Go API. Not part of the repo.
+import base from './vite.config.js';
+
+export default {
+ ...base,
+ preview: {
+ port: 5181,
+ host: '0.0.0.0',
+ proxy: base.server.proxy,
+ },
+};
diff --git a/src/components/agents/AddSkillsModal.jsx b/src/components/agents/AddSkillsModal.jsx
deleted file mode 100644
index 6bf4890..0000000
--- a/src/components/agents/AddSkillsModal.jsx
+++ /dev/null
@@ -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 (
-
-
- {picked.length
- ? `${picked.length} skill${picked.length === 1 ? '' : 's'} selected`
- : 'Nothing selected yet'}
-
- onOpenChange(false)}>Cancel
- { onAdd(picked); onOpenChange(false); }}>
- Add
-
- >
- }
- >
-
-
-
- {categories.length > 1 && (
-
- {categories.map((id) => (
- 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}
-
- ))}
-
- )}
-
-
- {visible.map((skill) => {
- const chosen = picked.includes(skill.id);
- return (
-
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'
- )}
- >
-
- {chosen && }
-
-
-
- {skill.name}
- {skill.description && (
-
- {skill.description}
-
- )}
- {skillSummary(skill)}
-
-
- );
- })}
-
- {!visible.length && (
-
- {available.length
- ? 'No skills match that.'
- : 'Every available skill is already attached to this agent.'}
-
- )}
-
-
-
- );
-}
-
-export default AddSkillsModal;
diff --git a/src/components/agents/AgentConfigure.jsx b/src/components/agents/AgentConfigure.jsx
index 59b2036..1b34dcb 100644
--- a/src/components/agents/AgentConfigure.jsx
+++ b/src/components/agents/AgentConfigure.jsx
@@ -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 {coveredLabels.join(', ')} . Anywhere else it is shown as constrained and declines.>
: 'No page selected — this agent has nowhere to answer yet.'}
-
- {SUPPORTED_SKILL_PAGES.map((page) => (
- set({
- pages: fields.pages.includes(page)
- ? fields.pages.filter((p) => p !== page)
- : [...fields.pages, page],
- })}
- >
- {pageLabel(page)}
-
- ))}
-
+ {/**
+ * 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 ? (
+
+ {fields.pages.map((page) => (
+
+ {pageLabel(page)}
+
+ ))}
+ {!fields.pages.length && (
+ No surface registered.
+ )}
+
+ ) : (
+
+ {/* 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) => (
+ set({
+ pages: fields.pages.includes(page)
+ ? fields.pages.filter((p) => p !== page)
+ : [...fields.pages, page],
+ })}
+ >
+ {pageLabel(page)}
+
+ ))}
+
+ )}
- 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.'}
@@ -361,12 +398,16 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
title="Skills"
count={fields.skills.length}
action={(
- setAddingSkills(true)}>
- Add skills
+
+ Browse catalog
)}
/>
+ {/* 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 ? (
{fields.skills.map((id) => {
@@ -386,18 +427,40 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
) : (
- No skills attached. This agent can still answer from the page's own reader.
+ No skills attached. This agent can still answer from the page's own reader —
+ or open the{' '}
+
+ skill catalog
+ {' '}
+ to give it one.
)}
- Authored in{' '}
+ Attached under{' '}
+
+ Skills
+
+ , where Owliver skills and Board skills both live. Definitions are written in the{' '}
- Workspace → Skills
+ skill library
+ .
@@ -666,14 +729,6 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
-
- set({ skills: [...fields.skills, ...ids] })}
- />
>
);
}
diff --git a/src/components/agents/skills/AgentSkillWorkspace.jsx b/src/components/agents/skills/AgentSkillWorkspace.jsx
new file mode 100644
index 0000000..f55c33c
--- /dev/null
+++ b/src/components/agents/skills/AgentSkillWorkspace.jsx
@@ -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 = (
+
+ );
+
+ return (
+
+ {dirty && (
+
+ Skill changes are being kept in the draft with them. Save the agent to store everything
+ together.
+
+ )}
+
+ {unknown.length > 0 && (
+
+ {unknown.join(', ')} — no definition with that id is registered, so it answers nothing.
+ Remove it from the tree, or restore the definition.
+
+ )}
+
+
+ {/* 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. */}
+
+
+
+
+ setTreeOpen(true)}
+ className="w-full sm:w-auto"
+ >
+
+ Agent tree
+
+ · {fields.skills.length} skill{fields.skills.length === 1 ? '' : 's'}
+
+
+
+
+ {/* 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. */}
+
+
+ {kind === OWLIVER ? (
+
+ ) : (
+
+ )}
+
+
+
+
setTreeOpen(false)} className="w-full">
+ Close
+
+ )}
+ >
+ {tree}
+
+
+
{ if (!next) setSelectedId(null); }}
+ isEnabledOn={isEnabledOn}
+ onToggle={onToggleSurface}
+ busy={busy}
+ fields={fields}
+ onRunTest={runCapabilityTest}
+ test={test}
+ />
+
+ );
+}
+
+export default AgentSkillWorkspace;
diff --git a/src/components/agents/skills/AgentTree.jsx b/src/components/agents/skills/AgentTree.jsx
new file mode 100644
index 0000000..336ba38
--- /dev/null
+++ b/src/components/agents/skills/AgentTree.jsx
@@ -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 (
+
+
+
+
+
+ {label}
+
+
+ {count}
+
+
+
+ {/* 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. */}
+
+
+
+ {children}
+ {count === 0 && (
+ {hint}
+ )}
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ 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'
+ )}
+ >
+
+ {name}
+ {/* 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. */}
+ {selected ? ' — selected' : ''}
+ {!entry && (
+ Unknown
+ )}
+
+
+ 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`}
+ >
+
+
+
+ );
+}
+
+/** A leaf that is only ever read — a tool reached, a note the agent was given. */
+function StaticLeaf({ icon: Icon, label, detail = null }) {
+ return (
+
+
+
+ {label}
+ {detail && {detail} }
+
+
+ );
+}
+
+/** @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 (
+
+ {/* The root. Not a disclosure: an agent that could be collapsed to
+ nothing would leave the panel showing a single closed row. */}
+
+
+ {Glyph
+ ?
+ : }
+
+
+
+ {agentName || 'New agent'}
+
+ Agent
+
+
+
+
+ {/* Where it answers. First, because it bounds everything under it: a
+ skill only answers on a page the agent covers. */}
+ onToggleBranch('surfaces')}
+ hint="No page selected — this agent has nowhere to answer yet."
+ >
+ {surfaces.map((surface) => (
+
+ ))}
+
+
+ {/* One Skills branch, two kinds inside it. */}
+ onToggleBranch('skills')}
+ hint="Nothing yet."
+ >
+ onToggleBranch('skills-owliver')}
+ hint="Nothing attached yet. Add one from the catalog."
+ sub
+ >
+ {skills.map((id) => (
+
+ ))}
+
+
+ {/* 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. */}
+ onToggleBranch('skills-board')}
+ hint="None drawing on this agent's surfaces."
+ sub
+ >
+ {boardSkills.map((entry) => (
+ x.label).join(', ')}
+ />
+ ))}
+
+
+
+ onToggleBranch('tools')}
+ hint="Tools arrive with skills. Attach a skill that declares one."
+ >
+ {tools.map((tool) => (
+
+ ))}
+
+
+ onToggleBranch('knowledge')}
+ hint="Nothing attached. Add knowledge under Configure."
+ >
+ {knowledge.map((entry, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default AgentTree;
diff --git a/src/components/agents/skills/BoardSkillList.jsx b/src/components/agents/skills/BoardSkillList.jsx
new file mode 100644
index 0000000..fae42d2
--- /dev/null
+++ b/src/components/agents/skills/BoardSkillList.jsx
@@ -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 (
+
+
+ {/* Soft Background Highlight on Hover */}
+
+
+
+
+
+
+
+
+
+ Board Skill
+
+
+
+
+
+ {enabled ? 'Active' : 'Disabled'}
+
+ onToggle(entry.id, next)}
+ aria-label={`${entry.name} — ${enabled ? 'on' : 'off'} for this workspace`}
+ />
+
+
+
+
+ {entry.name}
+
+
+ {entry.description && (
+
+ {entry.description}
+
+ )}
+
+ {/* What it actually draws. */}
+ {entry.sections.length > 0 && (
+
+ {entry.sections.map((section, i) => (
+
+ {section.title}
+ ·
+ {section.typeLabel}
+ ·
+
+ {section.pageLabel}
+
+ / {section.placementLabel}
+
+ ))}
+
+ )}
+
+
+
+
+ {entry.surfaces.some((s) => onSurfaces.has(s.id))
+ ? 'Active on this agent\'s pages'
+ : 'Draws across workspace'}
+
+
+
Edit
+
+
+
+
+ );
+}
+
+/** @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 (
+
+
+ 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.
+
+
+ {total > 0 && (
+
+ Search board skills
+
+
+ )}
+
+ {total > 0 && (
+
+
+ {ordered.length} of {total} board skill{total === 1 ? '' : 's'}
+
+
+
New board skill
+
+
+ )}
+
+ {ordered.length > 0 ? (
+
+ {ordered.map((entry) => (
+
+ ))}
+
+ ) : (
+
+ {total > 0 ? (
+ <>
+
+
No board skills match that
+
Try a different word.
+ >
+ ) : (
+ <>
+
+
No board skills yet
+
+ 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.
+
+
+
+ Add a board skill
+
+
+ >
+ )}
+
+ )}
+
+ );
+}
+
+export default BoardSkillList;
diff --git a/src/components/agents/skills/SkillCard.jsx b/src/components/agents/skills/SkillCard.jsx
new file mode 100644
index 0000000..7b1711b
--- /dev/null
+++ b/src/components/agents/skills/SkillCard.jsx
@@ -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 (
+
+
+
+
+
+ {/* Category, and the assignment control. */}
+
+
+
+
+
+
+ {entry.groupLabel}
+
+
+
+
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
+ ?
+ : enabled
+ ?
+ : }
+ {enabled ? 'Added' : 'Add Agent'}
+
+
+
+
+ {entry.name}
+
+
+ {entry.description && (
+
+ {entry.description}
+
+ )}
+
+ {/* One of the capability's own questions — the same suggestion Test
+ sends, so the card cannot show a question it would not ask. */}
+ {samplePrompt && (
+
+
+
+ “{samplePrompt}”
+
+
+ )}
+
+
+ {entry.surfaces?.map((s) => (
+
+
+ {s.label}
+
+ ))}
+
+ {entry.type === 'both' && (
+
+
+ Board & Owliver
+
+ )}
+
+
+
+ {/* Test and Details — neither of them changes the agent. */}
+
+
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`}
+ >
+
+ Test in Owliver
+
+
+
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
+
+
+
+
+
+ );
+});
+SkillCard.displayName = 'SkillCard';
+
+export default SkillCard;
diff --git a/src/components/agents/skills/SkillCatalog.jsx b/src/components/agents/skills/SkillCatalog.jsx
new file mode 100644
index 0000000..ba04d21
--- /dev/null
+++ b/src/components/agents/skills/SkillCatalog.jsx
@@ -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 (
+
+ {[{ id: 'all', label: 'All', count: total }, ...groups].map((group) => {
+ const active = value === group.id;
+ return (
+ 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}
+
+ {group.count}
+
+
+ );
+ })}
+
+ );
+}
+
+/** @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 (
+
+
+
+ Search skills
+
+
+
+
+
+
+
+ {/* The count is read from the same array the cards come from, so a
+ heading cannot claim six above four cards. */}
+
+ {entries.length} of {total} capabilit{total === 1 ? 'y' : 'ies'} for this scope
+ {attachedIds.size > 0 && · {attachedIds.size} enabled }
+
+
+ {/* Authoring stays reachable without being a destination of its own. */}
+
+
New skill
+
+
+
+ {entries.length > 0 ? (
+
+ {entries.map((entry) => (
+
+ ))}
+
+ ) : (
+
+
+
No skills match that
+
+ Try a different word, or widen the category filter.
+
+
+ )}
+
+ );
+}
+
+export default SkillCatalog;
diff --git a/src/components/agents/skills/SkillDetails.jsx b/src/components/agents/skills/SkillDetails.jsx
new file mode 100644
index 0000000..576b43e
--- /dev/null
+++ b/src/components/agents/skills/SkillDetails.jsx
@@ -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/` 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 (
+
+
+
+ {title}
+ {count !== undefined && ({count}) }
+
+ {children}
+
+ );
+}
+
+/** 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 (
+
+
+ {state.text}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ {entry.groupLabel}
+ {attached
+ ? Enabled for this agent
+ : Not enabled }
+ {entry.custom && Custom }
+
+
+
+
+ {/**
+ * 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') && (
+
+ 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.
+
+ )}
+
+ {described.length > 0 && (
+
+
+ {described.map((line) => (
+
+
+ {line}
+
+ ))}
+
+
+ )}
+
+ {shapes.length > 0 && (
+
+
+ {shapes.map((shape) => (
+
+ {shape}
+
+ ))}
+
+
+ )}
+
+ {/* 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 && (
+
+
+ {entry.suggestions.map((s) => {
+ const value = s.prompt || s.label;
+ const picked = canTest && question === value;
+ return (
+
+ 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'
+ )}
+ >
+ “{s.label}”
+ {picked && — chosen for the test }
+
+
+ );
+ })}
+
+
+ )}
+
+
+ {entry.surfaces.length > 0 ? (
+ <>
+
+ {entry.surfaces.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
+ A skill still only answers on the pages it names. Enabling it cannot widen that —
+ the agent's own surfaces narrow it further.
+
+ >
+ ) : (
+ This skill names no page, so it answers nowhere.
+ )}
+
+
+ {entry.tools.length > 0 && (
+
+ )}
+
+ {/* Test: the button and the outcome. The question and the answer live in
+ the Owliver panel, which is already on this page. */}
+ {showTest && (
+
+
+ {canTest
+ ? <>Runs in the Owliver panel on this page, against {target.label} . 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.'}
+
+
+
+ Test in Owliver
+
+
+
+
+ )}
+
+ );
+}
+
+/** @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 (
+
+ onOpenChange(false)}>Close
+ {/* The one control in this product that changes the agent's skills.
+ Last, under everything the decision should rest on. */}
+ onToggle(entry.id, surface)}
+ disabled={busy}
+ >
+ {attached
+ ? <> Disable for this agent>
+ : <> Enable for this agent>}
+
+ >
+ )}
+ >
+
+
+ );
+}
+
+export default SkillDetails;
diff --git a/src/components/agents/skills/SurfaceSelect.jsx b/src/components/agents/skills/SurfaceSelect.jsx
new file mode 100644
index 0000000..f28b6ed
--- /dev/null
+++ b/src/components/agents/skills/SurfaceSelect.jsx
@@ -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 (
+
+ {OPTIONS.map((option) => {
+ const available = option.supported(type);
+ const selected = available && value === option.id;
+ const Icon = option.icon;
+
+ return (
+ 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. */}
+
+ {selected && }
+
+
+
+
+
+ {option.label}
+
+
+ {available
+ ? option.hint
+ : `This capability does not declare ${option.id === 'both' ? 'both surfaces' : `a ${option.label} surface`}.`}
+
+
+
+ );
+ })}
+
+ );
+}
+
+export default SurfaceSelect;
diff --git a/src/components/agents/skills/glyphs.js b/src/components/agents/skills/glyphs.js
new file mode 100644
index 0000000..25d9640
--- /dev/null
+++ b/src/components/agents/skills/glyphs.js
@@ -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;
diff --git a/src/components/ai-assistant/AgentBadge.jsx b/src/components/ai-assistant/AgentBadge.jsx
new file mode 100644
index 0000000..fb406b0
--- /dev/null
+++ b/src/components/ai-assistant/AgentBadge.jsx
@@ -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 ;
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ Constrained
+
+ );
+}
+
+/** @param {any} props */
+export function AgentBadge({ page }) {
+ const { agent, covers } = useActiveAgent();
+
+ return (
+ <>
+ {agent
+ ?
+ : }
+
+
+
+ Owliver
+
+
+
{page}
+ {agent && !covers &&
}
+
+
+ >
+ );
+}
+
+export default AgentBadge;
diff --git a/src/components/ai-assistant/AgentSwitcher.jsx b/src/components/ai-assistant/AgentSwitcher.jsx
deleted file mode 100644
index fff3ab0..0000000
--- a/src/components/ai-assistant/AgentSwitcher.jsx
+++ /dev/null
@@ -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 ;
-
- return (
-
-
-
- );
-}
-
-/** The label a constrained agent carries, in the switcher and in the header. */
-function ConstrainedTag({ className = '' }) {
- return (
-
- Constrained
-
- );
-}
-
-/** One row in the list. */
-function AgentRow({ agent, active, covers, onSelect }) {
- return (
- 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'
- )}
- >
-
-
-
-
-
- {agent.name}
-
- {!covers && }
-
- {agent.description && (
-
- {agent.description}
-
- )}
-
-
- {active && (
-
- )}
-
- );
-}
-
-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 (
- <>
-
-
- >
- );
- }
-
- return (
-
-
-
-
-
-
-
-
- Owliver
-
-
-
-
- {page}
- {!covers && }
-
-
-
-
-
-
-
-
Switch agent
-
- Choose an agent to focus Owliver's scope on this page.
-
-
-
-
-
- Search agents
-
- 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"
- />
-
-
-
-
- {matches.map((candidate) => (
-
- ))}
-
- {!matches.length && (
-
No agents match that.
- )}
-
-
-
-
{ 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"
- >
-
- Browse all
-
-
-
{ 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"
- >
-
- Create agent
-
-
-
-
- );
-}
-
-export default AgentSwitcher;
diff --git a/src/components/ai-assistant/AssistantPanelContext.jsx b/src/components/ai-assistant/AssistantPanelContext.jsx
index 6572252..f7dfc28 100644
--- a/src/components/ai-assistant/AssistantPanelContext.jsx
+++ b/src/components/ai-assistant/AssistantPanelContext.jsx
@@ -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: () => {},
};
}
diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx
index 360802c..e974e91 100644
--- a/src/components/ai-assistant/KrowAssistant.jsx
+++ b/src/components/ai-assistant/KrowAssistant.jsx
@@ -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 = (
{/* Header */}
- {/* 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. */}
-
+ {/* 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. */}
+
{/* Window controls.
@@ -747,6 +798,46 @@ export default function KrowAssistant({
)}
+ {/**
+ * 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 && (
+
+
+
+
+ {panelTest.state === 'done' ? 'Tested'
+ : panelTest.state === 'error' ? 'Test failed' : 'Testing'}
+
+ {': '}
+ {panelTest.trace.skillName}
+ {panelTest.trace.agentName ? ` on ${panelTest.trace.agentName}` : ''}
+
+
+
+
+
+ )}
+
{/* ── Body: the only region that scrolls ─────────────────────────── */}
{
+ /**
+ * 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,
diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx
index c91aa2a..676a34f 100644
--- a/src/layouts/AdminLayout.jsx
+++ b/src/layouts/AdminLayout.jsx
@@ -262,8 +262,12 @@ export default function AdminLayout() {
navigate('/admin/profile#security')} className="cursor-pointer">
Security
-
navigate('/admin/workspace')} className="cursor-pointer">
- 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. */}
+ navigate('/admin/workspace/agents')} className="cursor-pointer">
+ Agent Registry
(
({
+ 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] : [];
+}
diff --git a/src/lib/skills/catalog.js b/src/lib/skills/catalog.js
new file mode 100644
index 0000000..39667d2
--- /dev/null
+++ b/src/lib/skills/catalog.js
@@ -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));
+}
diff --git a/src/lib/skills/registry.js b/src/lib/skills/registry.js
index c792215..ca70acb 100644
--- a/src/lib/skills/registry.js
+++ b/src/lib/skills/registry.js
@@ -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(
diff --git a/src/lib/skills/surfaces.js b/src/lib/skills/surfaces.js
index 71f1b79..340d48a 100644
--- a/src/lib/skills/surfaces.js
+++ b/src/lib/skills/surfaces.js
@@ -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);
/**
diff --git a/src/pages/admin/AgentDetail.jsx b/src/pages/admin/AgentDetail.jsx
index 7343e29..fb3260f 100644
--- a/src/pages/admin/AgentDetail.jsx
+++ b/src/pages/admin/AgentDetail.jsx
@@ -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() {
)}
setView('skills')}
+ scopeLocked={shipped}
+ />
+ )}
+ {view === 'skills' && (
+
)}
{view === 'test' && (
)}
{view === 'insights' && (
diff --git a/src/pages/admin/SkillDevelopment.jsx b/src/pages/admin/SkillDevelopment.jsx
index 505689f..caecdf3 100644
--- a/src/pages/admin/SkillDevelopment.jsx
+++ b/src/pages/admin/SkillDevelopment.jsx
@@ -198,7 +198,7 @@ export default function AdminSkillDevelopment() {
actions={
<>
navigate('/admin/workspace')}>
- Workspace
+ Agent Registry
navigate('/admin/university')}>
Manage training
diff --git a/src/pages/admin/Workspace.jsx b/src/pages/admin/Workspace.jsx
index dd5db94..75408b5 100644
--- a/src/pages/admin/Workspace.jsx
+++ b/src/pages/admin/Workspace.jsx
@@ -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 (
-
- {/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
-
-
-
-
-
-
- AI Capability Governance
+
+ {/* ── Hero ───────────────────────────────────────────────────────
+ The same banner, saying three plain numbers instead of two figures
+ and a word only this codebase uses. */}
+
+
+
+
+
+
+ AI Agent Registry
-
AI Agent Capabilities
-
- Govern what Owliver can understand, analyze and execute. Workforce training
- paths and talent progression are managed in KROW Forge.
+
+ AI Agent Registry
+
+
+ Manage the agents that power Owliver across Krow. Each agent decides which
+ pages it answers on and which skills it may use.
-
-
Registered Skills
-
{skills.length}
-
{activeSkills} Active
-
- {/* 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. */}
-
-
Skill Kinds
-
{owliverCount + boardCount}
-
{owliverCount} Owliver · {boardCount} Board
-
+ {metrics.map((metric) => (
+
+
+ {metric.label}
+
+
{metric.value}
+
+ ))}
- {/* ── 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. */}
-
-
-
-
-
Owliver Agents
+ {/* ── 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. */}
+
+
+
+
+
Agents
-
+
{agentCount} Registered
- 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.
+ {/* The hierarchy, said once, so the reader knows where skills live
+ before they click rather than after. */}
+
+
Agents
+
+
Configure
+
+
Skills
+
+
Owliver Skills
+
/
+
Board Skills
+
+
-
+
Manage Agents
-
+
- {/* ── 2. Full Width 2-Column Grid ───────────────────────────────── */}
-
- {/* ── Left Column: Owliver AI Capabilities ───────────────────── */}
-
-
-
-
-
-
Owliver Skills
-
-
- {owliverCount} Registered
-
-
-
-
- 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.
-
-
- {/* Skills Sample List */}
-
- {owliverSkills.slice(0, 4).map((s) => {
- const isActive = !disabledSkills.includes(s.id);
- return (
-
-
-
{s.name || s.id}
- {s.description &&
{s.description}
}
-
-
- {isActive ? 'Active' : 'Disabled'}
-
-
- );
- })}
-
-
- {/* CTA Button */}
-
-
-
- Manage Owliver Skills
-
-
-
-
-
-
- {/* ── 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. */}
-
-
-
-
-
-
Board Skills
-
-
- {boardCount} Registered
-
-
-
-
- Board skills add dynamic sections to KROW pages, read from real page data. They
- extend what a page shows, not what a candidate learns.
-
-
- {/* Sample list, from the same filtered registry read as the left
- column — so the two columns can never disagree about what is
- registered. */}
-
- {boardSkills.slice(0, 4).map((s) => {
- const isActive = !disabledSkills.includes(s.id);
- return (
-
-
-
{s.name || s.id}
- {s.description &&
{s.description}
}
-
-
- {isActive ? 'Active' : 'Disabled'}
-
-
- );
- })}
-
- {boardSkills.length === 0 && (
-
- No Board skills registered yet. A Board skill declares a section, where it
- sits and what it reads.
-
- )}
-
-
-
-
-
- Manage Board Skills
-
-
-
-
-
-
-
- {/* ── 3. Unified Registry Architecture Banner ───────────────────── */}
-
-
-
+ {/* ── 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. */}
+
+
+
AI skills and workforce training are different things
-
+
This page governs AI agent skills — what Owliver
- understands and what a Board skill draws on a page. Workforce
- training — training paths, proving challenges, skill verification and talent
+ understands, and what a Board skill draws on a page. Workforce
+ training — training paths, proving challenges, verification and talent
progression — is what a person learns, and it is managed in{' '}
KROW Forge.
- Both are Markdown in one registry, read through separate queries so neither appears in the
- other’s surface.
diff --git a/src/pages/admin/WorkspaceAgents.jsx b/src/pages/admin/WorkspaceAgents.jsx
index 1befab3..b1a574d 100644
--- a/src/pages/admin/WorkspaceAgents.jsx
+++ b/src/pages/admin/WorkspaceAgents.jsx
@@ -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"
>
Skills managed in
-
Workspace → Skills
+
Agent Registry → Skills
navigate('/admin/workspace')}>
- Workspace
+ Agent Registry
{/* Account definitions live in this browser's storage and nowhere
else. Import and export are what make that a place rather than a
diff --git a/vite.config.js b/vite.config.js
index dd9458a..7019f91 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -58,10 +58,9 @@ export default defineConfig({
*/
proxy: {
'/api': {
- target: process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:8080',
- // The API does not route on Host, and rewriting it would make the
- // Origin the backend sees disagree with the one the browser sent.
- changeOrigin: false,
+ target: process.env.VITE_API_PROXY_TARGET || 'https://mcp.korwfoce.com',
+ changeOrigin: true,
+ secure: false,
},
},
},