update the workspace and skills flow

This commit is contained in:
2026-08-20 11:04:13 +05:30
parent d3f7f439f6
commit 161b237695
8 changed files with 1100 additions and 204 deletions

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';

View File

@@ -1,4 +1,5 @@
import { parseSkill } from './registry';
import { boardPatch, owliverPatch, patchFrontmatter } from './skillFields';
/**
* Account-authored skills, as stored.
@@ -28,6 +29,22 @@ const frontMatter = ({ id, name, description, pages, fallback }) => [
'status: active',
].join('\n');
/**
* The identity block every definition opens with, and nothing else.
*
* The configuration that follows it — the `ui:` section, the `owliver:` block —
* is written by `boardPatch` and `owliverPatch`, which is the same writer the
* editors use on an existing file. A template that composed its own YAML was a
* second writer with a second field set, and the fields one knew about were not
* the fields the other did.
*/
const skeleton = (fields, fallback, body) => `---
${frontMatter({ ...fields, fallback })}
---
${body}
`;
/** A definition that draws a section on the pages it names. */
export const uiSkillTemplate = ({
id = '', name = '', description = '', pages = [],
@@ -40,26 +57,18 @@ export const uiSkillTemplate = ({
* record the page never had. A template must produce something that works
* before it is edited, so the default reads a source that needs nothing.
*/
type = 'flow', placement = '', source = 'candidates.activity', periods = [],
} = {}) => `---
${frontMatter({
id,
name,
description,
pages,
fallback: {
id: 'my-ui-skill',
name: 'My UI Skill',
description: 'What this skill adds to the page.',
},
})}
ui:
type: ${type}
${placement ? ` placement: ${placement}\n` : ''} title: ${name || 'My UI Skill'}
source: ${source}
${periods.length ? ` periods:\n${periods.map((p) => ` - ${p}`).join('\n')}\n` : ''}---
type = 'flow', placement = '', title = '', source = 'candidates.activity', periods = [],
} = {}) => {
const identity = { id, name, description, pages };
const fallback = {
id: 'my-ui-skill',
name: 'My UI Skill',
description: 'What this skill adds to the page.',
};
const heading = name || fallback.name;
# ${name || 'My UI Skill'}
return patchFrontmatter(
skeleton(identity, fallback, `# ${heading}
## Purpose
@@ -68,59 +77,36 @@ Describe what this section shows, and why it belongs on these pages.
## Capabilities
- Describe one thing the section reports.
- Add more as needed.
`;
- Add more as needed.`),
boardPatch({
...identity,
type,
placement,
/* A card with no title of its own is headed by the skill's name, which is
what the previous template wrote out. Kept, so a fresh draft reads the
same as it always did. */
title: title || heading,
source,
periods,
})
);
};
/** A definition that teaches Owliver what it can be asked for. */
export const owliverSkillTemplate = ({
id = '', name = '', description = '', pages = [],
triggers = [], suggestions = [], capabilities = [], source = '', periods = [],
triggers = [], suggestions = [], capabilities = [], responses = {},
} = {}) => {
const label = name || 'My Owliver Skill';
const lines = [`---
${frontMatter({
id,
name,
description,
pages,
fallback: {
id: 'my-owliver-skill',
name: label,
description: 'What this skill helps Owliver answer.',
},
})}`];
const identity = { id, name, description, pages };
const fallback = {
id: 'my-owliver-skill',
name: 'My Owliver Skill',
description: 'What this skill helps Owliver answer.',
};
const label = name || fallback.name;
lines.push('triggers:');
lines.push((triggers.length ? triggers : [label.toLowerCase()]).map((t) => ` - ${t}`).join('\n'));
lines.push('owliver:');
lines.push(' enabled: true');
if (suggestions.length) {
lines.push(' suggestions:');
lines.push(suggestions.map((s) => ` - ${s}`).join('\n'));
}
if (capabilities.length) {
lines.push(' capabilities:');
lines.push(capabilities.map((c) => ` - ${c}`).join('\n'));
if (source) {
lines.push(' responses:');
for (const capability of capabilities) {
lines.push(` ${capability}:`);
lines.push(` source: ${source}`);
if (periods.length) {
lines.push(' periods:');
lines.push(periods.map((p) => ` - ${p}`).join('\n'));
}
}
}
}
lines.push(`---
# ${label}
return patchFrontmatter(
skeleton(identity, fallback, `# ${label}
## Purpose
@@ -129,10 +115,17 @@ Describe what Owliver should be able to answer on these pages.
## Capabilities
- Describe one thing Owliver can be asked for.
- Add more as needed.
`);
return lines.join('\n');
- Add more as needed.`),
owliverPatch({
...identity,
/* A definition that claims no phrase of its own still answers to its
name — written out so the author can see what it will match on. */
triggers: triggers.length ? triggers : [label.toLowerCase()],
suggestions,
capabilities,
responses,
})
);
};
/**

View File

@@ -1,4 +1,5 @@
import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
import { SUPPORTED_OWLIVER_CAPABILITIES, sourceSupportsOption } from './surfaces';
/**
* A definition, read into editor fields — and edited fields, written back.
@@ -62,13 +63,33 @@ export const isReadableDefinition = (source) => hasFrontmatter(source);
/** The Board (UI) editor's fields, before a definition is loaded into them. */
export const EMPTY_BOARD_FIELDS = {
id: '', name: '', description: '', pages: [],
type: 'flow', placement: '', source: 'candidates.activity', periods: [],
type: 'flow', placement: '', title: '', source: 'candidates.activity', periods: [],
};
/** The Owliver editor's fields, before a definition is loaded into them. */
/**
* The Owliver editor's fields, before a definition is loaded into them.
*
* `responses` is keyed by capability, because that is how the format is keyed
* and how the runtime reads it. It used to be one `source` and one `periods`
* shared by every selected capability, and that single field is the whole of
* the bug this shape exists to remove:
*
* - A capability selected before a source was picked composed **no**
* `responses:` block at all. `normalizeSkillOwliver` then dropped every
* capability for want of a source, `owliver.capabilities` normalized to
* `[]`, and `owliverSkillsForContext` excludes a skill with none — so the
* definition registered, showed as active on its page, and contributed no
* suggestion chip.
* - Two capabilities could not read different sources, and one global source
* that suited the first often could not be drawn as the second: `list`
* against `candidates.activity` is refused by `normalizeSection`, so
* picking it silently cost the author that capability.
*
* A form that cannot express what the format can is not a shortcut to it.
*/
export const EMPTY_OWLIVER_FIELDS = {
id: '', name: '', description: '', pages: [],
triggers: [], suggestions: [], capabilities: [], source: '', periods: [],
triggers: [], suggestions: [], capabilities: [], responses: {},
};
/**
@@ -94,6 +115,10 @@ export function boardFieldsFromSource(source) {
pages: skill.pages,
type: section?.type || EMPTY_BOARD_FIELDS.type,
placement: section?.placement || '',
/* The heading the card draws. Composed from the skill's name when it is
left out, but it is a field of its own in the format and a definition
that set it must survive a round trip through the form. */
title: section?.title || '',
source: section?.source || EMPTY_BOARD_FIELDS.source,
periods: section?.periods || [],
};
@@ -113,8 +138,61 @@ export function owliverFieldsFromSource(source) {
try {
const skill = readSkill(source);
if (!skill) throw new Error('no frontmatter');
const first = skill.owliver.capabilities[0];
const response = first ? skill.owliver.responses[first] : null;
/**
* Every capability the definition *declares*, not only the ones that
* resolved.
*
* `skill.owliver.capabilities` is filtered to those that found a reading,
* which is right for the runtime and wrong for a form: a stored definition
* declaring `summary` and `list` where `list` lost its source would open
* with `list` simply absent, and the next save would delete a capability
* its author never removed. The one that cannot answer is exactly the one
* they opened the editor to fix, so it is shown — checked, with no source,
* and refused by the same validation as before.
*
* Read from the frontmatter the definition actually carries, and narrowed
* to the closed vocabulary, so a typo is still not a capability.
*/
const declared = (() => {
let data;
try {
({ data } = parseFrontmatter(String(source)));
} catch {
return [];
}
const block = data?.owliver;
if (!block || typeof block !== 'object' || Array.isArray(block)) return [];
const listed = Array.isArray(block.capabilities) ? block.capabilities : [];
const keyed = block.responses && typeof block.responses === 'object' && !Array.isArray(block.responses)
? Object.keys(block.responses)
: [];
return [...listed, ...keyed]
.map((capability) => String(capability).trim())
.filter((capability) => SUPPORTED_OWLIVER_CAPABILITIES.includes(capability));
})();
/* Declaration order first, then anything that resolved without being
listed — a definition whose capability came from its `ui:` section. */
const capabilities = [...new Set([...declared, ...skill.owliver.capabilities])];
/**
* Every capability's own reading, not the first one's.
*
* Reading one and showing it against all of them is how a two-capability
* definition lost the second's source the first time any field was edited:
* the form wrote back what it had read, and it had read half the block.
*/
const responses = Object.fromEntries(
capabilities.map((capability) => {
const response = skill.owliver.responses[capability];
return [capability, {
source: response?.source || '',
periods: response?.periods || [],
limit: response?.limit ?? null,
}];
})
);
return {
id: skill.id,
@@ -133,9 +211,8 @@ export function owliverFieldsFromSource(source) {
*/
triggers: skill.declaredTriggers ? skill.triggers : [],
suggestions: skill.owliver.suggestions.map((s) => s.label),
capabilities: skill.owliver.capabilities,
source: response?.source || '',
periods: response?.periods || [],
capabilities,
responses,
};
} catch {
return EMPTY_OWLIVER_FIELDS;
@@ -404,3 +481,115 @@ export function patchFrontmatter(source, patch = {}) {
return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`;
}
/* ── Writing fields back as one canonical patch ─────────────────────────── */
/**
* The frontmatter a set of Board fields means.
*
* Both composing paths go through this: a fresh draft applies it to an empty
* definition, an existing one applies it to the file already open. That is what
* makes "typed into the form" and "pasted as Markdown" the same artefact — the
* fields have one writer, so there is no second field set that only one path
* knows how to express.
*
* `existing` is the definition being edited, and only two questions are asked of
* it: whether its `pages:` are derived from its `ui:` entries, and whether its
* `ui:` block is one this form can represent at all. Both are refusals to
* overwrite what the fields cannot hold, never a second shape.
*/
export function boardPatch(fields, { existing = '' } = {}) {
const derived = existing ? pagesAreDerived(existing) : false;
const editable = existing ? uiIsEditableFromFields(existing) : true;
const section = editable ? {
'ui.type': fields.type || undefined,
'ui.placement': fields.placement || undefined,
'ui.title': fields.title || undefined,
'ui.source': fields.source || undefined,
/* Periods only where the reading has any. A source that counts nothing over
time keeps no period list, so switching to one cannot leave the previous
source's windows behind as a block that validates and does nothing. */
'ui.periods': fields.periods?.length && sourceSupportsOption(fields.source, 'periods')
? fields.periods
: REMOVE,
} : {};
return {
id: fields.id || undefined,
name: fields.name || undefined,
description: fields.description || undefined,
pages: fields.pages?.length && !derived ? fields.pages : undefined,
...section,
};
}
/**
* One capability's response, as the format writes it.
*
* Returns null when the capability has no source. That is deliberate and is the
* point of the whole change: an unconfigured capability is left out of
* `responses:` so `normalizeSkillOwliver` reports it by name and
* `validateSkillSource` refuses the save. The alternative — inventing a source
* to make the block well-formed — is how a definition gets saved reading data
* its author never chose.
*/
function responseFor(fields, capability) {
const response = fields.responses?.[capability];
const source = response?.source || '';
if (!source) return null;
const entry = { source };
if (response.periods?.length && sourceSupportsOption(source, 'periods')) {
entry.periods = response.periods;
}
if (response.limit && sourceSupportsOption(source, 'limit')) {
entry.limit = Number(response.limit);
}
return entry;
}
/**
* The frontmatter a set of Owliver fields means.
*
* Every selected capability that has a source is written with its *own*
* `source:`, and its own periods or limit where the reading takes them. Nothing
* is inherited from a `ui:` block here: this editor owns definitions that have
* no `ui:` block at all, and a response that relies on inheriting one silently
* loses its reading the day the section is edited.
*/
export function owliverPatch(fields, { existing = '' } = {}) {
const derived = existing ? pagesAreDerived(existing) : false;
const responses = {};
for (const capability of fields.capabilities || []) {
const response = responseFor(fields, capability);
if (response) responses[capability] = response;
}
return {
id: fields.id || undefined,
name: fields.name || undefined,
description: fields.description || undefined,
pages: fields.pages?.length && !derived ? fields.pages : undefined,
triggers: fields.triggers?.length ? fields.triggers : REMOVE,
'owliver.enabled': true,
'owliver.suggestions': fields.suggestions?.length ? fields.suggestions : REMOVE,
'owliver.capabilities': fields.capabilities?.length ? fields.capabilities : REMOVE,
/* An empty mapping is not a mapping the parser will take — `owliver.responses`
must be a mapping of capability names — so nothing configured removes the
key rather than writing a header with nothing under it. */
'owliver.responses': Object.keys(responses).length ? responses : REMOVE,
};
}
/**
* Which selected capabilities are not configured, by name.
*
* The editor shows these against the capability rather than only as a refusal
* on save, so "Summary needs a source" is read where the source is chosen. The
* *refusal* is still `validateSkillSource`'s, on the same definition the
* registry reads — this only says the same thing earlier.
*/
export const unconfiguredCapabilities = (fields) =>
(fields.capabilities || []).filter((capability) => !responseFor(fields, capability));

View File

@@ -438,6 +438,7 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Applications to this position, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
options: ['periods'],
},
{
id: 'position.pipeline',
@@ -452,6 +453,7 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Candidates matched to this position, best first.',
shapes: ['list', 'table', 'stats', 'card'],
options: ['limit'],
},
{
/**
@@ -469,6 +471,7 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'The candidate pool scored against this position, best first.',
shapes: ['list', 'table', 'stats', 'card', 'insight'],
options: ['limit'],
},
{
id: 'position.requirements',
@@ -504,6 +507,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Applications across the workspace, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
options: ['periods'],
},
{
id: 'positions.demand',
@@ -511,6 +515,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Open positions and what they still need.',
shapes: ['list', 'table', 'stats', 'card'],
options: ['limit'],
},
{
id: 'workforce.training',
@@ -518,6 +523,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Training paths and progress against them.',
shapes: ['progress', 'list', 'stats', 'table', 'card'],
options: ['limit'],
},
{
/* The vetting weights a position is being specified with. `positionId`
@@ -548,6 +554,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Who was hired, for which role, and when.',
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
options: ['limit'],
},
{
id: 'hires.performance',
@@ -562,6 +569,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'What has happened across the workspace, most recent first.',
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
options: ['limit'],
},
];
@@ -569,6 +577,60 @@ export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
/* ── Source / shape compatibility ───────────────────────────────────────────
One resolver, three consumers: `normalizeSection` refuses on it, the Board
editor's source picker filters on it, and the Owliver editor's per-capability
picker filters on it. They used to be three readings of `source.shapes` —
inline in the normalizer, absent from one editor and approximated in the
other — which is how a form could compose `list` against a source that has no
list in it and only find out at save. */
/**
* The section type a capability is drawn with, or null when it is prose.
*
* `summary` is the one capability with no component, so it is compatible with
* every source: the figures are read back as sentences rather than drawn.
*/
export const shapeForCapability = (capability) => owliverCapabilityFor(capability)?.shape || null;
/** Can this source be drawn as this shape? A null shape is prose, and always can. */
export function sourceSupportsShape(sourceId, shape) {
const source = dataSourceFor(sourceId);
if (!source) return false;
if (!shape) return true;
return Boolean(source.shapes?.includes(shape));
}
/**
* The sources that can fill this shape, optionally narrowed to what a placement
* can supply context for.
*
* `context` is the list a placement provides — `contextSuppliedBy(pages,
* placement)`. Passing it is how the Board editor offers only sources that can
* actually resolve where the section is mounted; the Owliver editor passes
* nothing, because a response with an unmet need asks which record is meant
* rather than rendering dead.
*/
export function sourcesForShape(shape, { context = null } = {}) {
return DATA_SOURCES.filter((source) => {
if (!sourceSupportsShape(source.id, shape)) return false;
if (!context) return true;
return !source.context || context.includes(source.context);
});
}
/**
* Whether a source reads an option a form can offer — `periods` or `limit`.
*
* Declared per source rather than inferred from its shapes, because the two do
* not line up: `positions.demand` is a list that honours `limit` and has no
* periods at all, while `candidates.activity` is the reverse. The editors used
* to guess from the shape list and offered period checkboxes that changed
* nothing.
*/
export const sourceSupportsOption = (sourceId, option) =>
Boolean(dataSourceFor(sourceId)?.options?.includes(option));
/**
* Can this reading be written back?
*

View File

@@ -1,7 +1,7 @@
import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, placementFor,
surfaceFor,
sourceSupportsShape, surfaceFor,
} from './surfaces';
/**
@@ -151,7 +151,10 @@ export function normalizeSection(raw, {
summary is prose, so there is no shape for a source to be incompatible
with. Every drawn type maps to itself. */
const shape = shapeFor(type);
if (shape && definition.shapes && !definition.shapes.includes(shape)) {
/* `sourceSupportsShape` rather than a reading of `definition.shapes` here:
the editors' source pickers ask the same question, and the answer has to
come from one place or a form can compose what the normalizer refuses. */
if (!sourceSupportsShape(source, shape)) {
errors.push(
`${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.`
);

View File

@@ -12,11 +12,12 @@ import { customSkillSource, owliverSkillTemplate, upsertCustomSkill } from '@/li
import { reportSave } from '@/lib/skills/saveFeedback';
import {
EMPTY_OWLIVER_FIELDS, facetsFromSource, isReadableDefinition, normalizeUpload,
owliverFieldsFromSource, pagesAreDerived, patchFrontmatter,
owliverFieldsFromSource, owliverPatch, patchFrontmatter, unconfiguredCapabilities,
} from '@/lib/skills/skillFields';
import {
DATA_SOURCES, OWLIVER_CAPABILITIES, PERIODS, dataSourceFor, dataSourceLabel,
owliverCapabilityLabel, periodLabel, sectionTypeLabel, surfaceFor,
OWLIVER_CAPABILITIES, PERIODS, contextLabel, dataSourceLabel, owliverCapabilityLabel,
periodLabel, sectionTypeLabel, shapeForCapability, sourceSupportsOption, sourcesForShape,
surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
@@ -186,32 +187,12 @@ export default function OwliverSkillEditor() {
return;
}
/* Responses are only rewritten when this form has enough to say what they
are. A definition whose capabilities read from an inherited `ui:` section
declares no `responses:` block, and inventing one here would pin a
reading the author deliberately left to be inherited. */
const responses = next.capabilities.length && next.source
? Object.fromEntries(next.capabilities.map((capability) => [
capability,
next.periods.length
? { source: next.source, periods: next.periods }
: { source: next.source },
]))
: undefined;
setSource(patchFrontmatter(source, {
id: next.id || undefined,
name: next.name || undefined,
description: next.description || undefined,
/* A definition whose reach comes from its `ui:` entries shows those pages
as a read-out; writing them back would pin a list that then governs the
block. Same rule as the Board editor, for the same reason. */
pages: next.pages?.length && !pagesAreDerived(source) ? next.pages : undefined,
triggers: next.triggers?.length ? next.triggers : undefined,
'owliver.suggestions': next.suggestions?.length ? next.suggestions : undefined,
'owliver.capabilities': next.capabilities?.length ? next.capabilities : undefined,
'owliver.responses': responses,
}));
/* `owliverPatch` is the same writer the template composes a fresh draft
with, so a definition typed into this form and one pasted as Markdown are
the same artefact rather than two dialects of it. Each selected capability
carries its own source, periods and limit — nothing is inherited from a
`ui:` block, which this editor's definitions frequently do not have. */
setSource(patchFrontmatter(source, owliverPatch(next, { existing: source })));
};
const setStatus = (nextActive) => {
@@ -288,11 +269,23 @@ export default function OwliverSkillEditor() {
navigate('/admin/workspace/skills?tab=owliver');
};
/* Only the sources the declared pages can actually supply context for are
worth offering — a source needing a candidate is not answerable from the
Analytics panel, and offering it would validate and then never resolve. */
const source_ = draft.source ? dataSourceFor(draft.source) : null;
const timeBased = Boolean(source_?.shapes?.includes('flow') || source_?.shapes?.includes('timeline'));
/**
* One capability's response, edited.
*
* Keyed by capability because the format is: `owliver.responses.summary` and
* `owliver.responses.list` are two readings, and a form with one source field
* could only ever write one of them.
*/
const updateResponse = (capability, patch) => update({
responses: {
...draft.responses,
[capability]: { ...(draft.responses[capability] || {}), ...patch },
},
});
/* Capabilities selected but not yet configured. Shown against the capability
itself; `validateSkillSource` is still what refuses the save. */
const unconfigured = unconfiguredCapabilities(draft);
/* The other half of the same definition, when it has one. Read-only: this
editor does not own it, but hiding it would misrepresent the skill. */
@@ -312,7 +305,16 @@ export default function OwliverSkillEditor() {
>
<ChevronLeft aria-hidden="true" /> Skills
</Button>
<Button size="sm" shape="rounded" onClick={save} loading={updatePreferences.isPending}>
{/* A capability with no source cannot be saved: `validateSkillSource`
refuses it, and refusing at the button says so before the author
has left the form. */}
<Button
size="sm"
shape="rounded"
onClick={save}
disabled={unconfigured.length > 0}
loading={updatePreferences.isPending}
>
{editingId ? 'Save skill' : 'Add skill'}
</Button>
</>
@@ -441,21 +443,31 @@ export default function OwliverSkillEditor() {
</Surface>
</section>
{/* ── What an answer looks like ──────────────────────────────── */}
{/* ── What an answer looks like, and what each one reads ────── */}
<section className="space-y-3">
<SectionTitle title="Capabilities" meta="The shapes an answer can take" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="grid gap-2.5 sm:grid-cols-2">
{OWLIVER_CAPABILITIES.map((capability) => {
const checked = draft.capabilities.includes(capability.id);
return (
<label
key={capability.id}
className={cn(
'flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 transition-colors',
checked ? 'border-krow-blue/30 bg-krow-blue-tint/50' : 'border-border bg-surface hover:bg-surface-subtle'
)}
>
<SectionTitle title="Capabilities" meta="Each one reads its own source" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-2.5">
{OWLIVER_CAPABILITIES.map((capability) => {
const checked = draft.capabilities.includes(capability.id);
const response = draft.responses[capability.id] || {};
/* The shapes a source must be able to fill for this capability.
`summary` is prose and has none, so every source suits it. */
const shape = shapeForCapability(capability.id);
/* One compatibility resolver, the same one `normalizeSection`
refuses on — so this list cannot offer what the save will
reject. */
const options = sourcesForShape(shape);
const missing = checked && !response.source;
return (
<div
key={capability.id}
className={cn(
'rounded-lg border transition-colors',
checked ? 'border-krow-blue/30 bg-krow-blue-tint/40' : 'border-border bg-surface'
)}
>
<label className="flex cursor-pointer items-start gap-2.5 px-3 py-2.5">
<Checkbox
checked={checked}
onCheckedChange={() => update({
@@ -471,58 +483,105 @@ export default function OwliverSkillEditor() {
<span className="block text-caption leading-relaxed text-ink-3">{capability.summary}</span>
</span>
</label>
);
})}
</div>
</Surface>
</section>
{/* ── What it reads ──────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Data" meta="The same resolver the pages use" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Field
label="Source"
hint="A named reading of data KROW already holds. A UI skill naming the same source reads exactly the same records."
>
<Select value={draft.source || undefined} onValueChange={(value) => update({ source: value })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.label} — {s.summary}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{timeBased && (
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
const selected = draft.periods.includes(period.id);
return (
<button
key={period.id}
type="button"
onClick={() => update({
periods: selected
? draft.periods.filter((p) => p !== period.id)
: [...draft.periods, period.id],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
{/* The response this capability answers with. Shown only
when it is selected, because an unselected capability has
no response to configure — and every selected one must
have a source of its own before the definition will
save. */}
{checked && (
<div className="space-y-3 border-t border-border/60 px-3 py-3">
<Field
label="Source"
hint={`A named reading of data KROW already holds. Only sources that can be answered as ${capability.label.toLowerCase()} are offered.`}
error={missing ? `${capability.label} needs a source before this skill can be saved.` : undefined}
>
<span>{period.label}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
<Select
value={response.source || undefined}
onValueChange={(value) => updateResponse(capability.id, { source: value })}
>
<SelectTrigger aria-label={`${capability.label} source`}>
<SelectValue placeholder="Select a data source" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label} — {option.summary}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{/* A reading that needs a record says so. Not a refusal:
an Owliver response with an unmet need asks which
position is meant and answers once told — which is
exactly what a page card cannot do. */}
{response.source && (
<p className="text-caption text-ink-3">
{sourcesForShape(shape).find((o) => o.id === response.source)?.context
? `Reads ${contextLabel(sourcesForShape(shape).find((o) => o.id === response.source).context)}. Owliver asks which one when the page has none open.`
: 'Reads across the workspace, so it answers with nothing selected.'}
</p>
)}
{sourceSupportsOption(response.source, 'periods') && (
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
const selected = (response.periods || []).includes(period.id);
return (
<button
key={period.id}
type="button"
onClick={() => updateResponse(capability.id, {
periods: selected
? (response.periods || []).filter((p) => p !== period.id)
: [...(response.periods || []), period.id],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{period.label}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</Field>
)}
{sourceSupportsOption(response.source, 'limit') && (
<Field label="Limit" hint="How many records the answer lists. Left empty, the reading uses its own default.">
<Input
type="number"
min="1"
max="50"
value={response.limit ?? ''}
aria-label={`${capability.label} limit`}
onChange={(e) => updateResponse(capability.id, {
limit: e.target.value ? Number(e.target.value) : null,
})}
/>
</Field>
)}
</div>
)}
</div>
</Field>
);
})}
{unconfigured.length > 0 && (
<p className="text-caption text-destructive">
{unconfigured.map(owliverCapabilityLabel).join(', ')}
{unconfigured.length === 1 ? ' has' : ' have'} no source yet.
A capability with nothing to read is dropped when the definition is registered,
so the skill would save and then offer no suggestion at all.
</p>
)}
</Surface>
</section>

View File

@@ -11,12 +11,13 @@ import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/ski
import { customSkillSource, uiSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import { reportSave } from '@/lib/skills/saveFeedback';
import {
EMPTY_BOARD_FIELDS, boardFieldsFromSource, facetsFromSource, isReadableDefinition,
EMPTY_BOARD_FIELDS, boardFieldsFromSource, boardPatch, facetsFromSource, isReadableDefinition,
normalizeUpload, pagesAreDerived, patchFrontmatter, uiIsEditableFromFields,
} from '@/lib/skills/skillFields';
import {
DATA_SOURCES, PERIODS, SECTION_TYPES, contextLabel, contextSuppliedBy, dataSourceLabel,
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, surfaceFor,
PERIODS, SECTION_TYPES, contextLabel, contextSuppliedBy, dataSourceLabel,
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, sourceSupportsOption,
sourcesForShape, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
@@ -188,29 +189,13 @@ export default function SkillEditor() {
return;
}
/* Only the section keys this form can faithfully represent. A definition
/* `boardPatch` is the same writer the template composes a fresh draft
with, and it is what decides which keys this form may own: a definition
written as a list, or addressing pages by name, may hold several sections
across several pages; overwriting that from four single-valued fields
would throw away everything but the first, so those fields are read-only
against it. Identity and reach still patch normally — a multi-page
across several pages, and single-valued fields would throw away all but
the first. Identity and reach still patch normally — a multi-page
definition must still be renameable. */
const section = uiIsEditableFromFields(source) ? {
'ui.type': next.type || undefined,
'ui.placement': next.placement || undefined,
'ui.source': next.source || undefined,
'ui.periods': next.periods?.length ? next.periods : undefined,
} : {};
setSource(patchFrontmatter(source, {
id: next.id || undefined,
name: next.name || undefined,
description: next.description || undefined,
/* Only when the definition owns a `pages:` key. A definition whose reach
comes from its `ui:` entries is showing a read-out here, and writing it
back would pin a list that governs the block from then on. */
pages: pages.length && !pagesAreDerived(source) ? pages : undefined,
...section,
}));
setSource(patchFrontmatter(source, boardPatch({ ...next, pages }, { existing: source })));
};
const setStatus = (nextActive) => {
@@ -450,6 +435,17 @@ export default function SkillEditor() {
</Field>
</div>
<Field
label="Card title"
hint="The heading the section draws. Left empty, it is headed by the skill's name."
>
<Input
value={meta.title}
placeholder={meta.name || 'Board'}
onChange={(e) => syncFromMeta({ ...meta, title: e.target.value })}
/>
</Field>
<Field
label="Data source"
hint="A named reading of data KROW already holds. An Owliver skill naming the same source reads exactly the same records."
@@ -457,7 +453,15 @@ export default function SkillEditor() {
<Select value={meta.source} onValueChange={(source) => syncFromMeta({ ...meta, source })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((src) => {
{/* Only the sources that can be drawn as the chosen
component, from the one resolver `normalizeSection`
refuses on — a picker that offers a shape the source
cannot fill is a form composing what the save rejects.
The ones that *could* be drawn but have no record here
stay visible and disabled, with the reason: an author who
cannot find a source they know exists assumes it was
removed. */}
{sourcesForShape(meta.type).map((src) => {
const why = sourceUnavailable(src);
return (
<SelectItem key={src.id} value={src.id} disabled={Boolean(why)}>
@@ -469,6 +473,7 @@ export default function SkillEditor() {
</Select>
</Field>
{sourceSupportsOption(meta.source, 'periods') && (
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
@@ -497,6 +502,7 @@ export default function SkillEditor() {
})}
</div>
</Field>
)}
{!uiIsEditableFromFields(source) && (
<p className="border-t border-border/50 pt-3 text-caption leading-relaxed text-ink-4">