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

@@ -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">