candidates and board ui agent issue
Some checks failed
CI / check (push) Failing after 4m58s

This commit is contained in:
2026-09-05 10:46:06 +05:30
parent e02a0c23d4
commit 6249e00a3a
78 changed files with 15071 additions and 1998 deletions

View File

@@ -394,6 +394,10 @@ export function AgentSkillWorkspace({
busy={busy}
query={boardQuery}
onQueryChange={setBoardQuery}
/* Ownership, through the one handler that writes `agent.skills` —
the same one the Owliver catalog attaches with. */
attachedIds={attachedIds}
onAttach={onToggleSkill}
/>
)}
</div>

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { LayoutTemplate, Pencil, Plus, SearchX } from 'lucide-react';
import { LayoutTemplate, Minus, 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';
@@ -9,24 +9,30 @@ import { Alert, Button, SearchInput, Switch } from '@/components/ds';
*
* 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:
* ever again.
*
* **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.
* **Two controls, because there are two different questions.** This card used to
* offer only the workspace switch, and said so at length: a Board skill was not
* carried by an agent, because `SkillSurface` read the page and the account's
* `disabledSkills` and never looked at `agent.skills`. That was true, and it is
* not any more — `useSkillSections` now asks `agentPermitsSkill`, so an id
* written into `agent.skills` is honoured on the page.
*
* 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.
* So both questions are asked here, and neither is dressed up as the other:
*
* - **Active** is the workspace switch. Off means off for everyone, on every
* page, for every agent. It is the account's `disabledSkills`.
* - **Add to agent** is ownership. It writes this skill's id into
* `agent.skills`, through the same handler the Owliver catalog uses.
*
* Ownership is opt-in and that is what makes the two safe together: a skill no
* agent claims still draws wherever its `pages:` say, exactly as before. The
* first agent to claim it is what narrows it — which is why attaching is worth
* saying out loud on the card rather than leaving as a silent side effect.
*/
/** One Board skill: what it draws, where, and whether it is switched on. */
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
const switchId = `board-${entry.id}`;
return (
@@ -110,6 +116,31 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
)}
</div>
{/* Ownership, separate from the workspace switch above it. The button
writes `agent.skills` through the same handler the Owliver catalog
uses — there is no second assignment path and nothing new stored. */}
{onAttach && (
<div className="relative z-10 mt-3 flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle px-2.5 py-2">
<span className="min-w-0 text-caption text-ink-3">
{attached
? 'Owned by this agent'
: 'Not owned by any agent on this page'}
</span>
<Button
size="xs"
variant={attached ? 'outline' : 'default'}
shape="rounded"
disabled={busy}
onClick={() => onAttach(entry.id)}
aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
>
{attached
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove</>
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add to agent</>}
</Button>
</div>
)}
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
<span className="text-caption text-ink-4">
{entry.surfaces.some((s) => onSurfaces.has(s.id))
@@ -131,6 +162,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
/** @param {any} props */
export function BoardSkillList({
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
attachedIds = null, onAttach = null,
}) {
const searchId = React.useId();
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
@@ -147,9 +179,11 @@ export function BoardSkillList({
return (
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
<Alert tone="info" title="Board skills belong to a page, not to an agent">
They draw sections on KROW pages and are switched on for the whole workspace —
so a change here affects every agent answering on that page, not just this one.
<Alert tone="info" title="Two switches, two different questions">
<strong>Active</strong> is workspace-wide: off means off on every page, for every
agent. <strong>Add to agent</strong> is ownership — once any agent owns a board
skill, it draws only where an owning agent is answering. A skill no agent owns
keeps drawing wherever its pages say, as before.
</Alert>
{total > 0 && (
@@ -192,6 +226,8 @@ export function BoardSkillList({
onToggle={onToggle}
onSurfaces={onSurfaces}
busy={busy}
attached={Boolean(attachedIds?.has(entry.id))}
onAttach={onAttach}
/>
))}
</ul>

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { usePreferences } from '@/lib/krowHooks';
import { allAgents } from '@/lib/agents/registry';
import { AGENTS, readAgentRegistry } from '@/lib/agents/registry';
import { sourcesFrom, useAgentDefinitions } from '@/lib/agents/agentStore';
import {
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
} from '@/lib/agents/runtime';
@@ -73,12 +74,30 @@ function writeSelection(selection) {
export function AgentProvider({ contextId = null, children }) {
const preferences = usePreferences();
/* Shipped definitions plus anything this account has authored, read through
the one registry so the switcher and the management page cannot disagree
about what exists. */
/**
* Shipped definitions plus anything this account has authored.
*
* Read from `agent-definitions` — the store the Agent Registry and Agent
* Configure write to — rather than from `preferences.customAgents`, which is
* where authored agents used to live. That move happened for the management
* screens and this was left behind, so the panel's idea of an agent was the
* shipped file and nothing else: an agent edited in Configure looked saved,
* and the agent answering beside it was still the version off disk.
*
* It is only visible once something actually depends on an authored field.
* Attaching a skill is that: ownership is read off `agent.skills`, and an
* attachment made in Configure has to be the one the page sees, or the two
* halves of the product disagree about what this agent owns.
*/
const definitions = useAgentDefinitions();
const shippedIds = React.useMemo(() => new Set(AGENTS.map((a) => a.id)), []);
const stored = React.useMemo(
() => sourcesFrom(definitions.data || [], shippedIds),
[definitions.data, shippedIds]
);
const agents = React.useMemo(
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
[preferences.customAgents, preferences.customSkills]
() => readAgentRegistry(stored, { customSkills: preferences.customSkills || [] }).agents,
[stored, preferences.customSkills]
);
const [selection, setSelection] = React.useState(() => readSelection());

View File

@@ -2,14 +2,14 @@ import * as React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
History, Home, Maximize2, Minimize2, PanelRightClose, Trash2, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import {
useAssignments, useAssignWorkers, useCreateEmployeeRole, useCreateJobPosting,
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
useGenerateJobDescription,
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
useUpdateJobPosting,
@@ -17,7 +17,9 @@ import {
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { runAction } from '@/lib/skills/actions';
import { allSkills, pageKeyForContext } from '@/lib/skills/registry';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { allSkills, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
import { actionSuggestions } from '@/lib/skills/tools';
import { suggestionChips } from '@/lib/skills/serverSuggestions';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
@@ -39,7 +41,7 @@ import { PromptChips } from './PromptChips';
*
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
* fresh `[]` each time would rerender the row — and anything memoized against
* it — throughout the whole of a query that is still too short to match.
* it — on every turn that offers nothing.
*/
const EMPTY_PROMPTS = [];
@@ -53,41 +55,6 @@ const EMPTY_PROMPTS = [];
*/
const EMPTY_SUGGESTIONS = [];
/**
* How long a pause counts as having finished typing.
*
* Short enough that the chips feel like they are keeping up, long enough that a
* word typed at speed is one request rather than eight. The endpoint is cached
* per query, so a reader deleting back to something already asked pays nothing
* either way.
*/
const SUGGEST_DEBOUNCE_MS = 180;
/**
* A value, held still until it stops changing.
*
* Deliberately generic and local: it debounces the composer's contents and
* nothing else, and the alternative — debouncing inside the query hook — would
* make every other caller of that hook pay for a delay it did not ask for.
*/
function useDebounced(value, delay) {
const [settled, setSettled] = React.useState(value);
React.useEffect(() => {
/* An emptied composer settles immediately. Waiting would leave the previous
query's chips under a blank input for a fifth of a second, which reads as
the panel not having noticed. */
if (!value) {
setSettled(value);
return undefined;
}
const timer = setTimeout(() => setSettled(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return settled;
}
/**
* Owliver History — the conversations that came before.
*
@@ -169,77 +136,98 @@ function HistoryView({ groups, currentId, onOpen, onForget }) {
}
/**
* Show the Back to Home row on scroll direction, not scroll position.
* Whether the composer is offering anything, as one rule in one place.
*
* Hidden while reading downwards, back the instant the user scrolls up — the
* way out of a long answer should not be something you have to scroll all the
* way to the top to reach. Direction is read off the panel's own scrolling
* element (the body region below), never the window: the page behind the panel
* does not move when the thread does.
* Extracted because it is the whole of the interaction and every clause is a
* decision somebody could reasonably make differently:
*
* The listener is passive and rAF-throttled, and `setVisible` is only ever
* called with a value that can change — React bails out on an identical one, so
* a fast scroll costs at most one render per direction change.
*
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
* routed through here so a programmatic jump is not mistaken for the user
* scrolling down and does not hide the control under them.
* focused — an offer belongs to the thing you are about to type into. An
* unfocused composer showing suggestions is the panel talking
* first.
* !busy — nothing is offered while an answer is still arriving; the next
* question is not knowable until this one lands.
* chat — History is a different view with a different body.
* count — nothing to say, nothing shown, and this clause now carries what
* a `!typed` test used to. Typing does not hide the panel by
* rule; it changes what the panel HAS. An empty composer offers
* follow-ups, a typed action intent offers the matching actions,
* and arbitrary partial text matches no action and so offers
* nothing — which is the same outcome by a more honest route,
* and the reason "Create" can be answered while "he" cannot.
*/
function useDirectionalNav(scrollRef, { active, resetKey }) {
const [visible, setVisible] = React.useState(true);
const lastY = React.useRef(0);
export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean(
focused && !busy && view === 'chat' && count > 0
);
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
produce: a real direction change clears this within one frame. */
const NOISE = 4;
/**
* The questions on offer, above the composer they belong to.
*
* A labelled panel rather than a bare row of chips: the label is what makes
* three sentences read as an offer rather than as something the assistant just
* said. It sits inside the composer's own region, above the input and below the
* conversation, so it reads as part of the thing you are about to type into.
*
* Always mounted, height animated. Mounting on open would move the input the
* instant the panel appeared and again when it left, so the caret would jump
* under the reader's hands every time they focused the box. Animating a
* collapsed height keeps the geometry continuous, and `pointer-events-none`
* plus `inert`-style tab removal means the closed panel cannot be clicked or
* tabbed into.
*
* `max-h` is generous enough for four wrapped questions and scrolls past that,
* so a narrow panel on a small screen cannot push the composer off the bottom.
*/
function SuggestedQuestions({ prompts, open, onSelect }) {
return (
<div
className={cn(
'overflow-hidden transition-all duration-200 ease-out motion-reduce:transition-none',
open
? 'max-h-56 translate-y-0 opacity-100'
: 'pointer-events-none max-h-0 translate-y-1 opacity-0'
)}
aria-hidden={open ? undefined : 'true'}
>
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
Suggested questions
</p>
{/* The existing chip component, and the existing submit path behind it —
`runPrompt` is the same handler a typed question goes through. */}
<PromptChips
prompts={prompts}
onSelect={onSelect}
align="start"
focusable={open}
className="pb-1"
/>
</div>
);
}
const pinToBottom = React.useCallback(() => {
/**
* Keep the thread pinned to its newest content.
*
* All that survives of a floating "Back to Home" row that used to sit between
* the header and the conversation. That row was `absolute`, so it did not take
* part in the layout — it OVERLAID the top of the scrolling region, and the
* first line or two of a long answer arrived underneath it. It hid on
* down-scroll to compensate, which meant the fix for a control covering the
* answer was to make the control disappear while you read.
*
* The navigation it carried now lives in the header, where the panel's other
* controls already are and where nothing can cover the response. What is left
* here is the scroll behaviour, which was always a separate concern that had
* been folded in because the two happened to share a listener.
*
* Direct `scrollTop` rather than smooth scrolling: at streaming frequency a
* smooth scroll never catches up and the thread visibly lags the text.
*/
function usePinToBottom(scrollRef) {
return React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
/* Adopt the new position before the scroll event lands, so the next read
sees no delta and the row keeps whatever state the user left it in. */
lastY.current = el.scrollTop;
}, [scrollRef]);
React.useEffect(() => {
const el = scrollRef.current;
/* Nothing to hide when the row is not rendered — and a fresh thread or a
newly opened panel always starts with it showing. */
setVisible(true);
if (!el || !active) return undefined;
lastY.current = el.scrollTop;
let frame = 0;
const read = () => {
frame = 0;
const y = el.scrollTop;
if (y <= 0) {
lastY.current = y;
setVisible(true);
return;
}
const delta = y - lastY.current;
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
rather than being swallowed frame by frame. */
if (Math.abs(delta) < NOISE) return;
lastY.current = y;
setVisible(delta < 0);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(read);
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, [scrollRef, active, resetKey]);
return { visible, pinToBottom };
}
/**
@@ -449,13 +437,18 @@ export default function KrowAssistant({
* so adding a conversation is adding an entry here rather than another prop
* threaded through the panel.
*/
const createRole = useCreateEmployeeRole();
const createRole = useCreateWorkerWithRole();
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
const result = runAction('create_employee_role', { draft, skill, status });
if (result?.type !== 'create_employee_role') return null;
return createRole.mutateAsync(result.data);
}, [createRole]);
/* The page's layout session, mounted by the layout above both this panel and
the page. Null on a surface that composes no tree, which is every page that
has not migrated — and every branch that reads it checks first. */
const uiEditing = useUiEditing();
const flowWriters = React.useMemo(() => ({
position: createPosition,
'employee-role': createEmployeeRole,
@@ -516,13 +509,16 @@ export default function KrowAssistant({
* used here — it was computed before the row existed.
*/
const queryClient = useQueryClient();
const refreshSuggestions = React.useCallback(async () => {
const refreshSuggestions = React.useCallback(async ({ query = '' } = {}) => {
if (!owliverPage) return [];
const fresh = await queryClient.fetchQuery({
/* The empty query string is the untyped request — the same key
`useOwliverSuggestions` uses when the composer is empty. */
queryKey: ['owliverSuggestions', owliverPage, ''],
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
/* An empty query is the untyped request — the same key
`useOwliverSuggestions` uses when the composer is empty, and what a
write wants: the page changed, so ask what matters on it now.
A question passed in ranks the same catalogue AGAINST that question,
which is what makes a follow-up follow from something. */
queryKey: ['owliverSuggestions', owliverPage, query],
queryFn: () => fetchOwliverSuggestions({ page: owliverPage, query }),
staleTime: 0,
});
return suggestionChips(fresh || [], context.id);
@@ -619,7 +615,11 @@ export default function KrowAssistant({
onNavigate: goToPage,
onAction: performAction,
flowWriters,
uiEditing,
companies,
/* The postings this caller can already see — the evidence behind
role-aware certification suggestions. */
postings: facts.postings || [],
workers,
onRefreshSuggestions: refreshSuggestions,
onUpdatePosition,
@@ -670,12 +670,12 @@ export default function KrowAssistant({
const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending;
/* The Back to Home row only exists in the states that are not already home. */
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
active: showBackRow,
resetKey: `${view}:${conversationId || ''}`,
});
const pinToBottom = usePinToBottom(scrollRef);
/* Whether there is anything to leave. The header's own control is shown on
exactly the states that are not already home — the same test the removed
row used, now deciding a button rather than an overlay. */
const canGoHome = view === 'history' || messages.length > 0 || Boolean(pending);
/* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */
@@ -685,60 +685,98 @@ export default function KrowAssistant({
);
/**
* What the chip row actually shows, which is one of three separate things.
* What the composer offers, and when.
*
* They are separate states, not one merged list, because they answer to
* different owners. Follow-ups belong to the answer that raised them; the
* suggestions belong to the server. Only one of them can be true at a time,
* and the order below is that precedence.
* Three rules, and they are about DIFFERENT questions — what to show, and
* whether to show anything at all.
*
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
* the answers to it — the role list after "create a position". They are
* never capped and never filtered, and they stand until the next turn or
* until the reader starts typing something else.
* WHAT. Before the first question, the page's own suggested questions: the
* reader has asked nothing, so there is nothing to follow up and the useful
* offer is the range of what this page can answer. After an answer, the
* follow-ups that answer carried — questions this conversation has not
* already covered, worked out in `nextSteps`. Never both: a thread that has
* run out of new ground shows nothing rather than falling back to the
* catalogue it has already been through.
*
* 2. The server's suggestions. From the moment there is something in the
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
* can usefully answer for this query, and its reply is rendered in the
* order it arrived. The panel does not rank, score, filter or reorder
* it: which readings exist depends on the caller's role and on what is
* actually in the database, and neither of those is knowable here.
* WHEN. Only while the composer has focus and is empty. Suggestions used to
* appear from the second character typed, which is the wrong moment twice
* over: a reader who is typing has already decided what to ask, and two
* characters is not enough to know what they mean. So typing hides them and
* the reader's own text is never touched.
*
* 3. Nothing. An empty composer offers no chips at all. The panel used to
* open on a dozen of them, which taught the range of what could be asked
* by saying all of it at once and pushed the composer — the thing the
* reader came for — under a wall of suggestions. The greeting still
* carries the page's context; `buildIntro` reads the same fact sheet it
* always did.
* The typed-query branch is gone with it. The endpoint still takes a query
* and `nextSteps` still uses it — that is what makes a follow-up follow from
* something — but nothing asks it on a keystroke any more.
*/
const followUp = messages[messages.length - 1]?.followUp;
const typed = input.trim();
const [composerFocused, setComposerFocused] = React.useState(false);
/**
* The request behind (2), debounced.
*
* The endpoint is cheap and cached per query, but a keystroke is not a
* decision — a reader typing "positions" would otherwise fire nine requests
* to see the answer to the ninth. A short delay means one request per pause,
* and `placeholderData` in the hook keeps the previous answer on screen
* meanwhile so the row does not empty and refill.
*
* Only asked while there is something in the composer. An empty one offers no
* chips, so there would be nothing to render the answer into — and a reader
* who starts typing has left the follow-up behind, which is why typing
* supersedes it rather than being ranked against it.
*/
const debouncedQuery = useDebounced(typed, SUGGEST_DEBOUNCE_MS);
const { data: suggested = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
/* The page's own questions, for a thread that has not started. One untyped
request, cached by the hook, asked only while it could be shown. */
const { data: pageSuggestions = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
page: owliverPage,
query: debouncedQuery,
enabled: Boolean(debouncedQuery),
enabled: Boolean(owliverPage) && messages.length === 0,
});
/**
* What is on offer, which now depends on whether anything has been typed.
*
* TYPED — the actions this page can perform that the text is starting to
* name, and nothing else. "Create" reaches "Create a position" here because
* that is a skill on this page that declares an action; "he" reaches nothing,
* and neither does "abc". This is the narrow case the composer was missing:
* a reader typing an action intent had to finish the sentence unaided, while
* a reader typing anything at all used to get the whole page catalogue.
*
* EMPTY — the follow-ups the last answer left, or, before a thread starts,
* what this page can be asked. Unchanged.
*/
const reachableSkills = React.useMemo(
() => skillsForContext(context.id, disabledSkills, preferences.customSkills || []),
[context.id, disabledSkills, preferences.customSkills]
);
const prompts = React.useMemo(() => {
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
return suggestionChips(suggested, context.id);
}, [typed, followUp, suggested, context.id]);
if (typed) return actionSuggestions(typed, reachableSkills);
if (messages.length) return followUp?.length ? followUp : EMPTY_PROMPTS;
return suggestionChips(pageSuggestions, context.id);
}, [typed, reachableSkills, messages.length, followUp, pageSuggestions, context.id]);
const showSuggestions = shouldShowSuggestions({
focused: composerFocused, busy, view, count: prompts.length,
});
/**
* Focus, read at the composer rather than at the input.
*
* A chip lives inside the same region, so moving to one keeps the region
* focused and the panel open long enough for the click to land — which a
* `blur` handler on the textarea alone would not do. `relatedTarget` is what
* makes "clicked outside" mean it: focus leaving for anywhere else in the
* document closes the panel.
*/
const onComposerBlur = React.useCallback((event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setComposerFocused(false);
}, []);
/**
* Asking closes the panel, and only focus reopens it.
*
* A blur handler alone was not enough, which a live run showed: after a
* question was sent, focus ended up on `document.body` while `composerFocused`
* was still true, so the panel came back on its own under the finished answer
* with nobody's cursor in the box. The subtree re-renders while the answer
* streams, and a focus lost that way does not always arrive as a blur this
* handler sees.
*
* So submitting is treated as what it is — the reader has finished with the
* composer for now — rather than relying on a blur that may never come. The
* state table is unchanged: focus opens it, everything else leaves it shut.
*/
React.useEffect(() => {
if (busy) setComposerFocused(false);
}, [busy]);
/* The newest assistant turn, which is the one that carries the rating. */
const lastAnswerIndex = React.useMemo(
@@ -891,6 +929,10 @@ export default function KrowAssistant({
different places with different affordances. The panel does the
first job only; the registry behind it is unchanged. */}
<div className="flex items-center gap-0.5">
{/* The way back to a clean panel, positioned in front of History */}
{canGoHome && (
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
)}
{/* History lives with the other window controls rather than in the
body, so the layout of the panel is unchanged whether or not
there is anything to show. It toggles: pressing it again returns
@@ -903,9 +945,6 @@ export default function KrowAssistant({
aria-pressed={view === 'history'}
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
/>
{messages.length > 0 && view === 'chat' && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
{expanded
? onRestore && (
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
@@ -919,37 +958,6 @@ export default function KrowAssistant({
</div>
</div>
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
{showBackRow && (
<div
className={cn(
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
transition-all duration-200 ease-out motion-reduce:transition-none`,
backVisible
? 'translate-y-0 opacity-100 pointer-events-auto'
: '-translate-y-full opacity-0 pointer-events-none'
)}
aria-hidden={backVisible ? undefined : 'true'}
>
<button
type="button"
onClick={goHome}
tabIndex={backVisible ? undefined : -1}
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
>
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
<span>Back to Home</span>
</button>
<span className="truncate text-[11px] font-medium text-ink-3">
{view === 'history'
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
: context.page}
</span>
</div>
)}
{/**
* A capability test running through this panel.
*
@@ -1059,23 +1067,18 @@ export default function KrowAssistant({
</div>
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
{/* All suggestions on the landing screen, where they teach what can be
asked. Capped once a thread exists, because from then on the vertical
space belongs to the conversation. Expanded fits more per line, so it
can afford one more.
Follow-ups are never capped: when Owliver has asked a question, its
chips *are* the answers, and hiding three of the six roles would make
the flow look broken. */}
{!busy && view === 'chat' && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
align="start"
/>
)}
{/* Focus is tracked on the whole region rather than on the textarea, so
reaching for a suggestion does not close the panel out from under the
click. In normal flow, never floating: an overlay here would sit on
top of the answer, which is the mistake the removed Back to Home row
made. The body above is `flex-1`, so it yields the height and the
response stays whole and scrollable. */}
<div
className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5"
onFocusCapture={() => setComposerFocused(true)}
onBlurCapture={onComposerBlur}
>
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
{composer}
<p className="px-1 text-[10px] leading-tight text-ink-4">
Owliver reads this page&apos;s data. Check anything you act on.

View File

@@ -24,7 +24,13 @@ import { cn } from '@/lib/utils';
* Arrow keys move between chips, so the whole set is one tab stop.
*/
/** @param {any} props */
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
export function PromptChips({
prompts = [], onSelect, max = 0, align = 'center', className = '',
/* Taken out of the tab order while the row is collapsed but still mounted:
a chip inside a zero-height container is invisible, and a Tab that lands on
something invisible is a keyboard user losing their place. */
focusable = true,
}) {
const chipRefs = React.useRef([]);
const visible = max ? prompts.slice(0, max) : prompts;
@@ -59,6 +65,7 @@ export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center',
type="button"
onClick={() => onSelect(prompt)}
onKeyDown={(e) => onKeyDown(e, i)}
tabIndex={focusable ? undefined : -1}
title={prompt.prompt}
className={cn(
/* 14px is the radius `rounded-full` already produces on a one-line

View File

@@ -22,10 +22,42 @@ import { usePageAction } from './PageContext';
* snapshot, and settled blocks must not re-render with it.
*/
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
* carried by blocks, not by markup inside a paragraph. */
/**
* Where a link in an answer is allowed to point.
*
* An allow-list, and it is a security boundary rather than a tidiness rule: the
* text being parsed here was written by a model, and a model reading a document
* that says "link to javascript:…" is exactly the injection §I7 of CLAUDE.md
* calls untrusted input. Anything not on this list renders as the plain text it
* came from — visible, inert, and obvious.
*
* `#` alone is deliberately absent. A bare empty anchor is the shape the
* citation sanitiser removes; one that reaches here is a link to nowhere, and
* showing its label as text is better than an anchor that does nothing.
*/
const isSafeHref = (href) => (
/^\/(?!\/)/.test(href) // in-app route
|| /^#[^\s]+$/.test(href) // an anchor on this page, but not a bare '#'
|| /^https?:\/\//i.test(href) // the open web
|| /^mailto:[^\s]+$/i.test(href)
);
/**
* Inline `**bold**`, `_italic_` and `[label](href)`.
*
* Links were the gap: `markdownToBlocks` never touched them, and this renderer
* had no case for them, so `[staffing policy](#staffing)` reached the reader as
* its own source. Structure is still carried by blocks rather than by markup —
* this stays three constructs, not a markdown library.
*
* A citation-shaped link never arrives here at all. `[337042b3](#)` is removed
* upstream in `provider.js`, by the wrapper's shape, before a block is built —
* so the two concerns stay apart: the sanitiser decides what is an internal
* reference, and this decides how a real link looks.
*/
function Inline({ value }) {
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
@@ -36,6 +68,33 @@ function Inline({ value }) {
if (part.startsWith('_') && part.endsWith('_')) {
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
}
const link = LINK.exec(part);
if (link) {
const [, label, href] = link;
if (!isSafeHref(href)) return part;
/* An in-app route goes through the router, like every other internal link
in this file — a full page load would throw away the conversation the
reader is being pointed away from. Everything else is an anchor, and
anything leaving the app opens away from it. */
const external = /^https?:\/\//i.test(href);
const className = 'font-medium text-krow-blue underline decoration-krow-blue/30 underline-offset-2'
+ ' transition-colors hover:decoration-krow-blue focus-visible:outline-none'
+ ' focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded-sm';
if (href.startsWith('/')) return <Link key={i} to={href} className={className}>{label}</Link>;
return (
<a
key={i}
href={href}
className={className}
{...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)}
>
{label}
</a>
);
}
return part;
});
}

View File

@@ -53,7 +53,22 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
return {
id: 'agent',
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
/**
* Every snapshot leaves through here, and every snapshot is sanitised.
*
* The wrapper is the point. Below it there are four ways a response gets
* built — streamed deltas, a completed run, a bounded run's trailing
* message, and the two failure notes — and only one of them passes through
* the markdown parser that removes citation ids. Sanitising at the yield
* rather than at each construction means a fifth way, added later, cannot
* reintroduce the leak by forgetting a call.
*/
async *stream(request) {
for await (const snapshot of this.run(request)) yield sanitizeBlocks(snapshot);
},
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
/* No agent, no run. The panel resolves which agent covers the page before
calling; reaching here without one means the routing layer changed and
this should say so rather than guess at an agent id. */
@@ -169,7 +184,9 @@ async function* readRunStream(response, signal) {
if (typeof event.delta === 'string') {
text += event.delta;
yield markdownToBlocks(text);
/* Still arriving: the frontier rules apply, so a citation split
across two frames is never rendered half-written. */
yield markdownToBlocks(text, { partial: true });
continue;
}
if (event.run) final = event.run;
@@ -226,6 +243,290 @@ function toBlocks(run) {
return blocks;
}
/* ── Citations ──────────────────────────────────────────────────────────── */
/**
* Why any of this exists.
*
* The backend asks the model to cite: `knowledge/context.go` tells it "Each
* <source> carries an id: cite it when you use what it says", and the
* `knowledge_search` tool repeats it. What neither does is say HOW — so the
* model picks a format, and picks a different one on a different day. The ids
* themselves are `knowledge_chunks.id`, which are UUIDs, and the model quotes
* them whole or truncated to their first block.
*
* The panel has no citation surface to render any of that into, so whatever
* shape the model chose arrives on screen as raw markup. The formats seen so
* far are a `<cite>` tag and a markdown link to an empty anchor; the rules
* below are written against the SHAPE of an identifier rather than against
* either format's syntax, so a third spelling of the same idea is far more
* likely to be caught than to be a new bug.
*
* A citation id is hex and dashes — a UUID or a leading run of one. That is
* what makes `10 applications`, `97% coverage` and `113 shifts` safe: decimal
* counts in prose are not addresses, are never inside citation syntax, and no
* rule here looks at a bare number.
*/
const CITATION_ID = String.raw`[0-9a-f]{4,}(?:-[0-9a-f]{4,})*`;
/**
* The tag spelling — `<cite id="…">…</cite>`, whatever attributes it carries.
*
* Matched by TAG NAME, never by id: the ids are minted per run, so a rule
* written against the ones in today's output would let tomorrow's through.
*/
const CITATION_TAG = /<\/?cit(?:e|ation)\b[^>]*>/gi;
/**
* The link spelling, and the brackets the model wraps a run of them in —
* `([337042b3](#), [2b94bc43](#))`.
*
* Identified by two conditions TOGETHER, never either alone: the target must be
* a bare `#` anchor, AND the label must look like an identifier rather than
* words. A real link has a real href, a real anchor link has a destination
* after the `#`, and a link a person would click has a label they could read.
* Requiring both is what keeps `[staffing policy](#staffing)`, `[Read more](/docs)`
* and even `[Read more](#)` on screen.
*
* The group is removed whole rather than link by link, because removing them
* one at a time leaves `(, )` behind — which reads worse than the ids did.
*/
const CITATION_GROUP = new RegExp(
String.raw`\s*\(\s*\[${CITATION_ID}\]\(#\)(?:\s*,\s*\[${CITATION_ID}\]\(#\))*\s*\)`,
'gi'
);
const CITATION_LINK = new RegExp(String.raw`\s*\[${CITATION_ID}\]\(#\)`, 'gi');
/**
* The prose spelling: the model narrating the attribute rather than marking it
* up — `(id `f34e8ef0-…`)`, `(reference `…`)`, `(source: `…`)`.
*
* Why this exists is the same reason the other two do. `context.go` hands the
* model `<source id="…">` and tells it to cite the id without saying how, so
* the model reaches for whatever syntax feels natural that day. This one is not
* markup at all — it is the id written out in a parenthesis, which is why no
* tag rule and no link rule saw it.
*
* THE WRAPPER IS WHAT IDENTIFIES IT, NEVER THE ID. That distinction is the
* whole rule and it has to survive future edits: a worker's record id is the
* same shape as a chunk id, so a rule that recognised ids rather than wrappers
* would delete real data off a profile. `Worker ID: f34e8ef0-…` has no
* parenthesis, so nothing here reaches it; `(id `f34e8ef0-…`)` is a
* parenthesis containing a reference word and nothing but ids, which is not a
* shape prose takes for any other reason.
*
* Backticks are optional on each side independently, because a model that opens
* a code span and forgets to close it before the bracket must not defeat this.
*/
const REF_WORD = String.raw`(?:ids?|refs?|references?|sources?|citations?)`;
const TICKED_ID = String.raw`\x60?${CITATION_ID}\x60?`;
const CITATION_LABELLED = new RegExp(
String.raw`\s*\(\s*${REF_WORD}\s*:?\s*${TICKED_ID}(?:\s*,\s*${TICKED_ID})*\s*\)`,
'gi'
);
/**
* Ids in square brackets with nothing else in them — `[uuid]`, `[uuid, uuid]`.
*
* A markdown link is `[label](target)`; a bracket holding only identifiers is
* not a link and is not something prose does. The lookahead leaves anything
* followed by `(` to the link rules, so a genuine link whose label happens to
* be a reference number keeps its destination and stays on screen.
*/
/* Stricter than CITATION_ID, and only here. A bare bracket has no reference
word to disambiguate it, so the id itself has to carry the evidence: at least
eight hex characters, or a dashed group. Without that `[2026]` is four hex
digits and a year in brackets would disappear from an answer. */
const BRACKETABLE_ID = String.raw`(?:[0-9a-f]{8,}|[0-9a-f]{4,}(?:-[0-9a-f]{4,})+)`;
const CITATION_BRACKETED = new RegExp(
String.raw`\s*\[\s*\x60?${BRACKETABLE_ID}\x60?(?:\s*,\s*\x60?${BRACKETABLE_ID}\x60?)*\s*\](?!\()`,
'gi'
);
/**
* Whatever is still arriving at the end of the text.
*
* The streaming half of the problem, and it is a real one rather than a
* theoretical one: `readRunStream` re-parses the WHOLE accumulated answer on
* every delta, so a citation split across two frames is a state the reader can
* see. `…before a first shift ([3370` renders for as long as the next delta
* takes to arrive.
*
* Both rules are anchored to the end of the text, so they can only ever
* describe the frontier of the stream and never something the answer has
* already moved past. The fragment is held back until it completes, at which
* point the rules above remove it properly — which is buffering, expressed as
* a parse rather than as a second copy of the text.
*/
const CITATION_TAG_PARTIAL = /<\/?[a-z]*$|<\/?cit(?:e|ation)\b[^>]*$/i;
const CITATION_LINK_PARTIAL = new RegExp([
/* An open bracket holding at least one COMPLETE citation and not yet closed:
`…shift ([337042b3](#)` and `…shift ([337042b3](#), `. Without this the
complete link is removed by the rule above and the `(` is stranded. */
String.raw`\s*\((?:\s*\[${CITATION_ID}\]\(#\)\s*,?)+\s*(?:\[[0-9a-f-]*(?:\](?:\(#?)?)?)?$`,
/* One part-written, with or without its bracket: `([3370`, `[337042b3`,
`[337042b3](`, `[337042b3](#`. */
String.raw`\s*\(?\s*\[[0-9a-f-]*(?:\](?:\(#?)?)?$`,
].join('|'), 'i');
/**
* The same two, part-written.
*
* `Policy (id `f34e8ef0-9a01-5921` is a state the reader can see, because the
* stream re-parses everything on every delta. Both are anchored to the end, so
* they describe only the frontier.
*
* The labelled rule accepts any short leading word rather than only a reference
* word, because `(i` and `(id` are prefixes of one and `(` is a prefix of
* everything. The cost is that an ordinary parenthetical is held back for the
* frames between its bracket and its first non-hex character —
* `Omar Haddad (hired 2026-01-0` waits, `…(hired 2026-01-04) starts` does not.
* A parenthesis arriving a frame late is not something a reader can notice; a
* half-written reference id is exactly what they reported.
*/
const CITATION_LABELLED_PARTIAL = new RegExp(
String.raw`\s*\((?:[a-z]{0,12}[\s:]*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?)?$`,
'i'
);
const CITATION_BRACKETED_PARTIAL = new RegExp(
String.raw`\s*\[\s*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?$`,
'i'
);
/**
* What a lifted citation leaves behind.
*
* `check ()`, `check (, )`, `check .` — the wrapper is gone and its punctuation
* is not, and a sentence ending in an empty bracket reads as broken markup
* rather than as a clean sentence. Applied after the removals, never before.
*/
const EMPTY_PARENS = /\s*\(\s*[,;]*\s*\)/g;
const SPACE_BEFORE_PUNCTUATION = /\s+([.,;:!?])/g;
const DOUBLED_SPACES = / {2,}/g;
/**
* Removes citation markup, keeping the sentence inside it.
*
* The wrapper is addressing, not content — it tells a client which retrieved
* chunk a claim came from — and with nowhere to render it the honest move is to
* show the claim and drop the envelope. The backend's citation metadata is
* untouched: it is still on the run, still in the trajectory, and this only
* decides what reaches a reader.
*
* Content is never altered, only the wrapper around it, so markdown inside a
* citation — bold, a bullet, a table row — parses exactly as it would have
* unwrapped.
*
* If the panel ever grows a real citation affordance, this is the seam: parse
* the ids out here into a block the renderer can draw, rather than discarding
* them. Nothing else has to move.
*/
export function stripCitations(markdown, { partial = false } = {}) {
let out = String(markdown ?? '')
.replace(CITATION_TAG, '')
.replace(CITATION_GROUP, '')
.replace(CITATION_LABELLED, '')
.replace(CITATION_BRACKETED, '');
/**
* The frontier rules, and ONLY while there is a frontier.
*
* They describe something that is still being written, so they are wrong to
* apply to a finished answer — and quietly so. `…and note [12]` ends in a
* bracket holding two hex characters, which is indistinguishable from the
* first two characters of an id still arriving. Mid-stream, holding it back
* for a frame is right. At the end of a completed answer there is nothing
* more coming, the bracket is all there will ever be, and removing it deletes
* a footnote marker from the reader's answer.
*
* The caller knows which it is: `readRunStream` passes `partial` on a delta
* and not on the final snapshot. That is the only place the distinction
* exists, so it is the only place it can be made.
*
* Order is load-bearing. `…shift ([337042b3](#),` is a COMPLETE link inside
* an unclosed bracket — strip the link first and `(,` is left on screen,
* which is the broken bracket this exists to prevent. Matching the
* unterminated group first takes the whole fragment.
*/
if (partial) {
out = out
.replace(CITATION_TAG_PARTIAL, '')
.replace(CITATION_LINK_PARTIAL, '')
.replace(CITATION_LABELLED_PARTIAL, '')
.replace(CITATION_BRACKETED_PARTIAL, '');
}
return out
.replace(CITATION_LINK, '')
.replace(EMPTY_PARENS, '')
.replace(SPACE_BEFORE_PUNCTUATION, '$1')
.replace(DOUBLED_SPACES, ' ');
}
/**
* Keys that carry text a person reads, on any block.
*
* An allow-list rather than a deny-list, because the two mistakes do not cost
* the same: missing a display field leaks an id, while sanitising an address
* field would corrupt a confirmation token, a route or a record id and break
* what it points at. A new block type gets its display keys covered for free; a
* new addressing key is safe by default.
*/
const DISPLAY_KEYS = new Set([
'text', 'sub', 'label', 'title', 'summary', 'caption',
'description', 'detail', 'note', 'heading', 'hint',
]);
/** Keys whose value is a list of sentences rather than one. */
const DISPLAY_LISTS = new Set(['items', 'warnings', 'details', 'lines', 'rows']);
/**
* Citation-proofs a whole response, whatever shape it arrived in.
*
* `markdownToBlocks` strips the model's markdown, and for a completed answer
* that is the whole story. It is NOT the whole story for the response: the same
* provider also emits `note(run.message)` when a run did not complete,
* `note(event.error.message)` when the stream fails, and `confirmation(payload)`
* carrying server wording composed around model-supplied arguments. None of
* those go through the markdown parser, so each was a way for an id to reach
* the DOM without passing the one place that removes them.
*
* Rather than a `stripCitations` call at each — three sites today, and a fourth
* the next time the provider learns to say something — every block the agent
* provider yields goes through here.
*
* Walks recursively so nested shapes are reached (a table's rows, a
* confirmation's warnings, an insight's items) and touches only the keys above:
* `token`, `id`, `route`, `to` and everything else addressing-like is left
* exactly as the server sent it.
*/
export function sanitizeBlocks(blocks) {
return (blocks || []).map((block) => sanitizeValue(block, null));
}
function sanitizeValue(value, key) {
if (typeof value === 'string') {
return DISPLAY_KEYS.has(key) || DISPLAY_LISTS.has(key) ? stripCitations(value) : value;
}
if (Array.isArray(value)) {
/* The key travels into the elements, strings and objects alike. A string in
`items` is display text; an object in `rows` is a row, and only the key
says so — its own cell keys are positional (`c0`, `c1`) and carry no
meaning at all. An object in `columns` still defers to its own keys. */
return value.map((entry) => sanitizeValue(entry, key));
}
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) {
/* A table row is `{ c0: 'Applied', c1: '4' }` — the cell keys are
positional and carry no meaning, so the row itself marks them. */
out[k] = key === 'rows' && typeof v === 'string' ? stripCitations(v) : sanitizeValue(v, k);
}
return out;
}
return value;
}
/**
* Turns a model's markdown into the block vocabulary the panel already renders.
*
@@ -247,8 +548,8 @@ function toBlocks(run) {
* parser would be a large dependency in exchange for handling footnotes nobody
* writes.
*/
export function markdownToBlocks(markdown) {
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
export function markdownToBlocks(markdown, { partial = false } = {}) {
const lines = stripCitations(markdown, { partial }).replace(/\r\n/g, '\n').split('\n');
const blocks = [];
let paragraph = [];
let listItems = null;
@@ -353,7 +654,15 @@ function parseTable(lines, start) {
* left alone — those go through `Inline`, which renders bold properly.
*/
function stripInline(value) {
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
return String(value)
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/`(.+?)`/g, '$1')
/* A link keeps its label and loses its target. Headings and cells are drawn
as plain strings by their components, so an anchor cannot survive here —
and the label alone reads correctly, where the raw `[label](href)` does
not. `Inline` renders the real thing everywhere a link CAN be one. */
.replace(/\[([^\]\n]+)\]\([^)\s]*\)/g, '$1')
.trim();
}
/**
@@ -438,7 +747,7 @@ export function createAssistantProvider() {
export function createUnconfiguredProvider() {
return {
id: 'unconfigured',
// eslint-disable-next-line require-yield
async *stream() {
yield [note(
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '

View File

@@ -1,4 +1,5 @@
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
import { resolveUiEdit } from './uiEdit';
import { ASSISTANT_CONTEXTS } from './contexts';
import { poolFor } from '@/lib/workforce';
import { matchSkill } from '@/lib/skills/registry';
@@ -634,7 +635,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
*/
function resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
companies = [], skillContext = null,
companies = [], postings = null, skillContext = null,
}) {
/**
* One question, one skill, then one way of answering it.
@@ -743,7 +744,7 @@ function resolveSkill({
*/
const registry = flowFor(skill);
if (registry) {
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies } }) };
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies, postings } }) };
}
/**
@@ -866,6 +867,7 @@ export function resolveIntent({
company question. Read off the postings the caller can already see, so it
expands nobody's view — see the `@companies` note in flows/position.js. */
companies = [],
postings = null,
/**
* The active agent, and where the reader is.
*
@@ -880,6 +882,14 @@ export function resolveIntent({
was one. Only the draft flow reads it; a typed question carries none and
resolves exactly as it always did. */
positionId = null,
/**
* The layout session for this page, when there is one.
*
* Carries the tree on screen and whether something is already being
* previewed. Absent — or on a page that composes no tree — every branch below
* resolves exactly as it did before this existed.
*/
ui = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
@@ -911,9 +921,21 @@ export function resolveIntent({
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
if (draftIntent) return draftIntent;
/**
* 1b. Changing the page itself.
*
* Ahead of the skills because a request to hide a section is about the
* interface, and a skill trigger reading the same words would answer about
* the data behind it. Tightly gated: `resolveUiEdit` returns null unless the
* page composes a tree AND the words name something on it, a registered
* panel type, or the layout — so an ordinary question is never taken.
*/
const uiIntent = resolveUiEdit({ question, ui });
if (uiIntent) return uiIntent;
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies,
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies, postings,
/* The envelope travels beside the collections rather than replacing them:
a resolver reads records, and the envelope says where the reader is. A
source that needs a position still finds it exactly where it always was. */

View File

@@ -0,0 +1,177 @@
import { doc, list, note, text } from './blocks';
import { matchUiEdit } from '@/lib/ui/intent';
import { outlineTree } from '@/lib/ui/inspect';
import { dataSourceLabel } from '@/lib/skills/surfaces';
/**
* Owliver's half of a layout change.
*
* Turns a request into an intent the panel can act on, and into the words that
* go back. The understanding itself is in `lib/ui/intent.js`; this decides what
* to say about it.
*
* Every outcome is one of four kinds, and the split matters:
*
* - `ui-preview` an operation to show, not to keep
* - `ui-apply` / `ui-discard` acting on what is already shown
* - `ui-answer` a question back, or a refusal — nothing changes
*
* A preview is never applied in the same turn. The person asked for a change;
* they have not yet seen it, and agreeing to something unseen is not agreement.
*/
/** The chips offered while something is being previewed. */
const PREVIEW_CHIPS = [
{ label: 'Apply', prompt: 'Apply the layout change' },
{ label: 'Discard', prompt: 'Discard the layout change' },
];
/**
* Read a layout request.
*
* Returns null for anything that is not one, which is most of what is typed —
* and returning null is what leaves every existing Owliver answer exactly as it
* was. The gate is in `matchUiEdit`: a verb alone is never enough.
*/
export function resolveUiEdit({ question, ui }) {
if (!ui?.available) return null;
const match = matchUiEdit(question, {
tree: ui.tree,
registry: ui.registry,
role: ui.role,
previewing: ui.previewing,
/* Which page this is. Owliver may only offer, and only accept, what this
page can actually hold — the same scope the visual editor's picker uses,
so the two can never disagree about what is addable here. */
page: ui.page,
});
if (!match) return null;
switch (match.kind) {
case 'inspect':
return { kind: 'ui-answer', doc: describe(ui.tree, ui.registry) };
case 'apply':
return {
kind: 'ui-apply',
doc: doc(text('Saved. This page will look like this the next time you open it.')),
};
case 'discard':
return {
kind: 'ui-discard',
doc: doc(text('Put back the way it was. Nothing was saved.')),
};
case 'plan':
return {
kind: 'ui-preview',
op: match.op,
doc: doc(
text(`${match.summary}. This is a preview — nothing is saved yet.`),
note('Choose Apply to keep it, or Discard to put it back.')
),
followUp: PREVIEW_CHIPS,
};
/**
* More than one thing fits.
*
* Named back rather than guessed at. Editing the wrong section while
* somebody is looking at another one is the failure the whole target
* resolver exists to avoid, and a coin toss here would reintroduce it.
*/
case 'ambiguous':
return {
kind: 'ui-answer',
doc: doc(
text('More than one part of this page fits that. Which did you mean?'),
list(match.candidates.map((node) => `${node.title || node.label} (${node.id})`))
),
followUp: match.candidates.slice(0, 3).map((node) => ({
label: node.title || node.label,
prompt: `${node.id}`,
})),
};
case 'unknown':
return {
kind: 'ui-answer',
doc: doc(
text('I could not find that on this page.'),
note('Ask what is on this page to see what can be changed.')
),
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
};
/** A type nobody has registered. Offered the real ones rather than invented. */
case 'unknown-type':
return {
kind: 'ui-answer',
doc: doc(
text('I do not have that kind of panel.'),
text(`I can use: ${match.offered.join(', ')}.`)
),
};
/**
* A shape with no reading named.
*
* The one place a data source could be invented, and the place it is most
* firmly refused: the choices come from the closed vocabulary, and the
* person picks.
*/
case 'needs-source':
return {
kind: 'ui-answer',
doc: doc(
text(`What should the ${match.type.label} show?`),
list(match.options.map(dataSourceLabel))
),
followUp: match.options.slice(0, 3).map((id) => ({
label: dataSourceLabel(id),
prompt: `Add a ${match.type.label} showing ${dataSourceLabel(id)}`,
})),
};
/**
* Asked to apply or discard with nothing being previewed.
*
* Answered here rather than left to fall through, because falling through
* sent the panel's own chip text to the model, which replied — correctly
* for what it is — that layout changes are not in its scope. The honest
* answer is that there is nothing to act on.
*/
case 'nothing-previewed':
return {
kind: 'ui-answer',
doc: doc(
text(match.op === 'apply'
? 'There is nothing to apply — no layout change is being previewed.'
: 'There is nothing to discard — no layout change is being previewed.'),
note('Ask what is on this page to see what can be changed.')
),
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
};
case 'refused':
return { kind: 'ui-answer', doc: doc(text(match.message)) };
default:
return null;
}
}
/** What is on the page, as a reading rather than a change. */
function describe(tree, registry) {
const lines = outlineTree(tree, { registry });
if (!lines.length) {
return doc(text('This page is not one I can rearrange yet.'));
}
return doc(
text('This page is made of these parts. You can hide, show or reorder any of them.'),
list(lines),
note('Say for example "hide the audit log" or "move the timeline to the top".')
);
}

View File

@@ -185,6 +185,60 @@ function normalizeDraftChip(chip) {
return chip;
}
/**
* One question, reduced to what it asks.
*
* Case, surrounding space and a trailing question mark are not differences, so
* "What should I do next?" and "what should i do next" are one question and are
* not offered twice.
*/
const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/[?.!]+$/, '');
/**
* The chips to offer after an answer: what this conversation has not covered.
*
* Two rules, and the second is the one that matters. The suggestions are ranked
* by the SERVER against the question just asked — the panel does not decide what
* is worth asking, it only decides what has already been said — and then
* anything this thread has asked or already offered is removed.
*
* Without that second rule the row repeats. A page carries a handful of intents
* and the top of that list barely moves between turns, so the same three chips
* come back after every answer, including the one the reader has just pressed.
* Removing what has been used leaves genuinely new ground each time and runs out
* honestly rather than looping.
*
* There is NO fallback to the page's own ranking, and that is the correction a
* live run forced. Asking "Summarize hiring activity" matches nothing in the
* catalogue, so nothing was excluded, so the fallback returned the page's top
* three — and the reader got "How healthy is the platform right now?" under an
* answer about hiring activity, which is the generic-catalogue behaviour this
* function exists to end. A page ranking is what to ask on a PAGE; it is not a
* follow-up to anything. When the conversation has no next question, the honest
* answer is none.
*/
export async function nextSteps({ question, history, refresh }) {
if (!refresh) return undefined;
const used = new Set([asQuestion(question)]);
for (const message of history) {
if (message.role === 'user') used.add(asQuestion(message.text));
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
}
const unused = (chips) => (chips || []).filter((chip) => {
const key = asQuestion(chip.prompt || chip.label);
if (!key || used.has(key)) return false;
/* A list that repeats itself within one turn is the same defect at a
smaller scale. */
used.add(key);
return true;
});
const onTopic = unused(await refresh({ query: question }));
return onTopic.length ? onTopic : undefined;
}
/**
* A stored thread, with any completed-then-reopen action stripped out.
*
@@ -246,6 +300,14 @@ export function useConversation({
* "and now employee roles too". This is one entry in a map.
*/
flowWriters = {},
/**
* The page's layout session, when the surface has one.
*
* Read for the tree Owliver inspects and called to preview or keep a change.
* Absent on every page that composes no tree, and every branch that touches
* it checks first — so the panel behaves exactly as it did before on those.
*/
uiEditing = null,
onAssignWorkers, onScheduleInterview,
/* Finishing a draft: the same two mutations the Create Position form calls.
Passed in rather than reached for, so this layer still writes nothing
@@ -256,6 +318,9 @@ export function useConversation({
/* The clients this organization already staffs for, offered as chips on the
company question. Derived from postings the caller can already read. */
companies = [],
/* The caller's own postings, which is where role-to-certification relevance
is observed from. See `certificationsForRole`. */
postings = null,
/* The worker profiles a declared role can be recorded against, as
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
workers = [],
@@ -491,7 +556,7 @@ export function useConversation({
skill,
...advanceFlow({
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
ctx: { roles, companies, workers },
ctx: { roles, companies, workers, postings },
}),
};
}
@@ -507,11 +572,24 @@ export function useConversation({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories, companies,
customSkills, roles, skillCategories, companies, postings,
courses, workforce, skillContext, positionId,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
ui: uiEditing
? {
available: Boolean(uiEditing.tree?.length),
tree: uiEditing.tree,
previewing: uiEditing.previewing,
role: uiEditing.role || null,
registry: uiEditing.registry || undefined,
/* Where the reader is standing. What can be added here is a
property of the page, not of the registry, and this is how
the conversation learns it. */
page: uiEditing.page || null,
}
: null,
}),
/* Only while a real agent is behind the panel. With the local
simulator there is nothing better to defer TO, and deferring
@@ -520,6 +598,37 @@ export function useConversation({
);
}
/**
* A layout change, acted on before the reply says what happened.
*
* Three kinds, and the split is the safety property: a preview is shown and
* nothing is stored; an apply keeps what was already shown; an answer — a
* question back, or a refusal — changes nothing at all. Owliver never
* applies in the same turn it proposes.
*
* `propose` validates against the tree on screen and refuses rather than
* previewing something that could not be kept, so a refusal here is
* reported in the words the engine gave rather than a generic apology.
*/
if (intent.kind === 'ui-preview' && uiEditing) {
const result = uiEditing.propose(intent.op);
if (!result.ok) {
intent = {
...intent,
kind: 'ui-answer',
doc: doc(textBlock(result.problems[0]?.message || 'That change is not possible here.')),
followUp: undefined,
};
}
} else if (intent.kind === 'ui-apply' && uiEditing) {
const result = await uiEditing.apply();
if (!result.ok) {
intent = { ...intent, doc: doc(textBlock('That change could not be saved.')) };
}
} else if (intent.kind === 'ui-discard' && uiEditing) {
uiEditing.discard();
}
/**
* The one step that writes. It happens before the reply rather than after,
* because the reply is the outcome — "Position created successfully" has to
@@ -785,9 +894,53 @@ export function useConversation({
// half-written answer loses what they were already reading. Stopping
// before the first block, though, should leave no empty turn behind.
if (latest.length) {
/**
* What to ask NEXT — which is not the same as what is worth asking.
*
* Every other path in this file ends its turn with `followUp`; the
* agent path was the one that did not, so an agent answer was the only
* kind that left the chip row empty.
*
* The first attempt at fixing that asked for the page's untyped
* suggestions, and they are ranked by signal rather than by the
* conversation — so a page with six intents offered its top three, and
* offered the same three after every answer, including the one that had
* just been asked. Three standing highlights repeated verbatim are not
* follow-ups; they are the landing screen redrawn under a reply.
*
* So the question is passed to the server as the query, which ranks the
* same catalogue against what was actually asked, and anything this
* thread has already asked or already offered is removed. What is left
* is what this conversation has not covered yet — which is what a
* follow-up is. When nothing is left, nothing is shown: a panel with
* nothing new to suggest should say so by being quiet, not by repeating
* itself.
*
* A stopped run is offered nothing. The reader interrupted the answer,
* so the next step it implies has not been established.
*/
let followUp;
if (!controller.signal.aborted) {
try {
followUp = await nextSteps({
question: text,
history: messagesRef.current,
refresh: onRefreshSuggestions,
});
} catch {
/* The answer arrived; failing to fetch what to ask next is not a
reason to withhold it. */
}
}
const next = [
...messagesRef.current,
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
{
role: 'assistant',
blocks: latest,
stopped: controller.signal.aborted || undefined,
...(followUp ? { followUp } : null),
},
];
messagesRef.current = next;
persist(next);
@@ -810,8 +963,13 @@ export function useConversation({
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext, companies, workers,
agent, agentCoversPage, agentSuggestion, owliverContext]);
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
agent, agentCoversPage, agentSuggestion, owliverContext,
/* The layout session changes as a page's composition and the account's
skills resolve, and a stale one means the tree Owliver inspects is the
empty one from the first render — so a layout request falls through to
the model and comes back as "I don't cover that". */
uiEditing]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);

View File

@@ -15,6 +15,11 @@ import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
from the module rather than the package index so a page section never pulls
the assistant panel in behind it. */
import { usePageAction, usePageContext } from '@/components/ai-assistant/PageContext';
/* Which agent is answering on this page. Imported from the module rather than
the package index for the same reason `PageContext` is: a page section must
not pull the assistant panel in behind it. */
import { useActiveAgent } from '@/components/ai-assistant/AgentContext';
import { agentPermitsSkill } from '@/lib/agents/runtime';
/**
* The extension point: one controlled slot a page offers to skills.
@@ -35,23 +40,71 @@ import { usePageAction, usePageContext } from '@/components/ai-assistant/PageCon
*/
/** The skills contributing sections to this page right now. */
function useSkillSections(page, placement) {
/**
* The sections definitions contribute to a page, optionally narrowed to one
* placement.
*
* Exported because the node tree needs the identical reading: a skill's section
* drawn as a child node and the same section drawn by this surface must come
* from one resolution, or the two would disagree about what a page carries.
* Called with no placement it returns every section on the page.
*/
export function useSkillSections(page, placement) {
const preferences = usePreferences();
/**
* Who is answering here.
*
* Read through the same context the panel reads, which is mounted around the
* page as well as around the panel — see `AssistantPanelContext`. Outside a
* provider this returns an inert value, so a surface drawn anywhere else sees
* no agents and no ownership, which is exactly today's behaviour.
*/
const { agent, agents } = useActiveAgent();
const customKey = JSON.stringify(preferences.customSkills || []);
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
/* Stable keys, because these are fresh arrays on every render and a memo
keyed on their identity would recompute forever. */
const ownedKey = JSON.stringify((agents || []).map((a) => a.skills || []));
const mineKey = JSON.stringify(agent?.skills || []);
return useMemo(() => {
const custom = JSON.parse(customKey);
const disabled = JSON.parse(disabledKey);
/**
* Agent ownership, and why it is opt-in.
*
* A skill that an agent claims belongs to that agent: its UI is drawn where
* that agent is answering and nowhere else. A skill that **no** agent claims
* is unowned, and unowned means unchanged — page scope and the account
* switch decide it, exactly as they did before this existed.
*
* That asymmetry is the whole design. Every skill this product ships is
* conversation-only and claimed for conversation; the definitions that draw
* page UI are authored on the account and claimed by nobody. Enforcing
* ownership on all of them would have removed every skill section in the
* product on the day it shipped. Attaching a skill to an agent is therefore
* the act that brings it under an agent's control — a decision an author
* makes in Agent Configure, not one taken on their behalf here.
*
* Three separate ideas meet here and none of them is the others: what the
* *agent* owns, what pages the *skill* declares, and what the *account* has
* switched off. All three must pass.
*/
/* The rule itself lives with the other agent rules, so it can be reasoned
about and tested without rendering a page. */
const scope = { agents: JSON.parse(ownedKey).map((skills) => ({ skills })), agent: { skills: JSON.parse(mineKey) } };
return allSkills(custom)
/* Inactive means registered but not offered — the same rule the assistant
follows, so switching a skill off removes its UI too. */
.filter((skill) => skill.status === 'active' && !disabled.includes(skill.id))
.filter((skill) => agentPermitsSkill(skill.id, scope))
.flatMap((skill) => sectionsForPage(skill, page)
.filter((section) => !placement || section.placement === placement)
.map((section) => ({ skill, section })));
}, [page, placement, customKey, disabledKey]);
}, [page, placement, customKey, disabledKey, ownedKey, mineKey]);
}
/**

View File

@@ -0,0 +1,233 @@
import React from 'react';
import { ArrowDown, ArrowUp, Eye, EyeOff, Replace, Trash2 } from 'lucide-react';
import { Button } from '@/components/ds';
import { cn } from '@/lib/utils';
import {
ALIGN_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, SPACING_VALUES,
} from '@/lib/ui/node';
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
import { nodeRegistry } from '@/lib/ui/registry';
import { hideOp, layoutOp, nudgeOp, presentationOp, propOp, removeOp, replaceOp } from './ops';
/**
* Everything a person may change about the selected node.
*
* Every control below is *derived*: the properties come from the type's own
* `propSchema`, the buttons from its declared `capabilities`, the replacement
* options from `registry.replacements`, and the layout values from the closed
* vocabulary in `lib/ui/node.js`. Nothing is listed by hand, which is what makes
* a type registered tomorrow editable tomorrow — and what makes it impossible
* for this panel to offer something the validator would refuse.
*
* There is no free-text style field anywhere, by construction. A person can set
* a title, pick from an enum, choose a column count — and there is no control
* that accepts a class name, a style, or markup, because no `propSchema`
* declares one and the layout vocabulary is four fixed steps.
*/
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
const labelled = 'block text-[10px] font-semibold uppercase tracking-wide text-ink-4';
export function NodeInspector({ node, siblings, page = null, onOperate, registry = nodeRegistry }) {
const entry = registry.get(node.type);
if (!entry) {
return <p className="text-caption text-ink-3">This node’s type is no longer available.</p>;
}
const can = (capability) => entry.capabilities.includes(capability) && !node.locked;
const index = siblings.findIndex((s) => s.id === node.id);
/** Reorder this node's own container by moving it one step. */
const nudge = (delta) => {
const op = nudgeOp(node, siblings, delta);
if (op) onOperate(op);
};
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
const replacements = can('replace') ? registry.replacements(node.type, { shapes, page }) : [];
return (
<div className="space-y-4">
<header className="space-y-1">
<p className="font-heading text-body font-semibold text-ink-1">{node.title || node.label}</p>
<p className="font-mono text-[10px] text-ink-4">{node.id} · {node.type} · {node.origin}</p>
</header>
{/* ── What may be done at all ─────────────────────────────────────── */}
<div className="flex flex-wrap gap-1.5">
{can('hide') && (
<Button
size="xs" variant="outline" shape="rounded"
onClick={() => onOperate(hideOp(node))}
>
{node.hidden
? <><Eye className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Show</>
: <><EyeOff className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Hide</>}
</Button>
)}
{can('move') && (
<>
<Button size="xs" variant="outline" shape="rounded" disabled={index <= 0}
onClick={() => nudge(-1)} aria-label="Move up">
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
<Button size="xs" variant="outline" shape="rounded" disabled={index < 0 || index >= siblings.length - 1}
onClick={() => nudge(1)} aria-label="Move down">
<ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</>
)}
{can('remove') && (
<Button size="xs" variant="outline" shape="rounded"
onClick={() => onOperate(removeOp(node))}>
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove
</Button>
)}
</div>
{/* ── Turn it into something else ─────────────────────────────────── */}
{replacements.length > 0 && (
<label className="block space-y-1">
<span className={labelled}>
<Replace className="mr-1 inline h-3 w-3" aria-hidden="true" />Show as
</span>
<select
className={field}
value=""
onChange={(e) => e.target.value && onOperate(replaceOp(node, e.target.value))}
>
<option value="">Keep {entry.label}</option>
{replacements.map((type) => (
<option key={type} value={type}>{registry.get(type)?.label || type}</option>
))}
</select>
</label>
)}
{/* ── Properties, exactly as the type declared them ───────────────── */}
{can('update') && node.editable.length > 0 && (
<div className="space-y-2.5">
{node.editable.map((prop) => (
<label key={prop.key} className="block space-y-1">
<span className={labelled}>{prop.label}{prop.required ? ' *' : ''}</span>
{prop.kind === 'enum' && (
<select
className={field}
value={prop.value ?? ''}
onChange={(e) => onOperate(propOp(node, prop.key, e.target.value))}
>
<option value="">—</option>
{prop.options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
)}
{prop.kind === 'boolean' && (
<input
type="checkbox"
checked={Boolean(prop.value)}
onChange={(e) => onOperate(propOp(node, prop.key, e.target.checked || null))}
/>
)}
{prop.kind === 'number' && (
<input
type="number" className={field} defaultValue={prop.value ?? ''}
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value === '' ? null : Number(e.target.value)))}
/>
)}
{prop.kind === 'string' && (
<input
type="text" className={field} defaultValue={prop.value ?? ''}
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value))}
/>
)}
</label>
))}
</div>
)}
{/* ── What it is reading ──────────────────────────────────────────── */}
{node.data && (
<div className="space-y-1">
<span className={labelled}>Reading</span>
<p className="rounded-lg border border-border bg-surface-subtle px-2 py-1.5 text-body-sm text-ink-2">
{dataSourceLabel(node.data.source)}
</p>
</div>
)}
{/* ── How it looks ────────────────────────────────────────────────
Offered only where the type says it can draw them, so a picker never
shows a setting the component would ignore. */}
{can('update') && (node.variants.length > 0 || node.densities.length > 0) && (
<div className="grid grid-cols-2 gap-2">
{node.variants.length > 0 && (
<Choice
label="Style" value={node.presentation.variant} options={node.variants}
onPick={(v) => onOperate(presentationOp(node, 'variant', v))}
/>
)}
{node.densities.length > 0 && (
<Choice
label="Density" value={node.presentation.density} options={node.densities}
onPick={(v) => onOperate(presentationOp(node, 'density', v))}
/>
)}
</div>
)}
{/* ── Layout: four closed vocabularies, nothing typed ─────────────── */}
{can('update') && (
<div className="grid grid-cols-2 gap-2">
<Choice
label="Columns" value={node.layout.columns}
options={range(entry.constraints.minColumns ?? MIN_COLUMNS, entry.constraints.maxColumns ?? MAX_COLUMNS)}
onPick={(v) => onOperate(layoutOp(node, 'columns', v))}
/>
<Choice
label="Span" value={node.layout.span} options={range(MIN_COLUMNS, MAX_COLUMNS)}
onPick={(v) => onOperate(layoutOp(node, 'span', v))}
/>
<Choice
label="Gap" value={node.layout.gap} options={GAP_VALUES}
onPick={(v) => onOperate(layoutOp(node, 'gap', v))}
/>
<Choice
label="Align" value={node.layout.align} options={ALIGN_VALUES}
onPick={(v) => onOperate(layoutOp(node, 'align', v))}
/>
<Choice
label="Space above" value={node.layout.spacingBefore} options={SPACING_VALUES}
onPick={(v) => onOperate(layoutOp(node, 'spacingBefore', v))}
/>
<Choice
label="Space below" value={node.layout.spacingAfter} options={SPACING_VALUES}
onPick={(v) => onOperate(layoutOp(node, 'spacingAfter', v))}
/>
</div>
)}
</div>
);
}
/** One closed-vocabulary picker. Empty means "leave it to the page". */
function Choice({ label, value, options, onPick }) {
return (
<label className="block space-y-1">
<span className={labelled}>{label}</span>
<select
className={cn(field, 'text-caption')}
value={value ?? ''}
onChange={(e) => onPick(e.target.value === '' ? null : coerce(e.target.value))}
>
<option value="">Default</option>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</label>
);
}
const range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
const coerce = (value) => (/^\d+$/.test(value) ? Number(value) : value);

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ds';
import { addableTypes } from '@/lib/ui/inspect';
import { nodeRegistry } from '@/lib/ui/registry';
import { dataSourceLabel, placementProvides, sourcesForShape } from '@/lib/skills/surfaces';
import { addOp } from './ops';
/**
* What can be added here, and what it would show.
*
* Both lists are answers to questions the product already knows how to answer:
* `addableTypes` asks the registry what this container accepts and what this
* role may use, and `sourcesForShape` asks the closed data vocabulary which
* readings can fill that shape. Nothing is offered that validation would then
* refuse, and nothing can be typed — a source is chosen from what exists, which
* is what makes an invented metric impossible to compose here.
*/
/** A property's value on a described node, read the way the inspector reads it. */
const propOf = (node, key) => node?.editable?.find((prop) => prop.key === key)?.value || null;
export function NodePicker({ tree, parent, page = null, role = null, onAdd, registry = nodeRegistry }) {
const [type, setType] = React.useState('');
const [source, setSource] = React.useState('');
const options = addableTypes(parent?.type ?? null, { registry, role, page });
const chosen = options.find((option) => option.type === type) || null;
/**
* What this location can actually answer.
*
* Every reading in the vocabulary was offered here, including the ones that
* need a record — so a Card bound to "Position activity" could be added to a
* page that has no position, and rendered "This section needs a position to
* read." forever. Offered, accepted, saved, and dead.
*
* `provides` on the surface already records which context each placement
* supplies, and `sourcesForShape` already filters on it. Only the question
* was missing. A node at the page root is inside no record, so it is offered
* the readings that need none.
*/
const slot = propOf(parent, 'placement');
const provided = slot ? placementProvides(propOf(parent, 'page') || page, slot) : [];
const sources = chosen?.dataShapes.length
? sourcesForShape(chosen.dataShapes[0], { context: provided }).map((s) => s.id)
: [];
/* A type that reads data cannot be added until a reading is picked. The
button says so by staying disabled rather than by failing on submit. */
const ready = Boolean(chosen) && (!chosen.dataRequired || Boolean(source));
const add = () => {
if (!ready) return;
onAdd(addOp(tree, parent, chosen.type, source || null));
setType('');
setSource('');
};
if (!options.length) {
return <p className="text-caption text-ink-4">Nothing can be added here.</p>;
}
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
return (
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
Add {parent ? `inside ${parent.title || parent.label}` : 'to the page'}
</p>
<select className={field} value={type} onChange={(e) => { setType(e.target.value); setSource(''); }}>
<option value="">Choose a panel…</option>
{options.map((option) => (
<option key={option.type} value={option.type}>{option.label}</option>
))}
</select>
{chosen?.dataShapes.length > 0 && (
<select className={field} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">What should it show?</option>
{sources.map((id) => <option key={id} value={id}>{dataSourceLabel(id)}</option>)}
</select>
)}
<Button size="xs" shape="rounded" disabled={!ready} onClick={add}>
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add
</Button>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Eye, EyeOff, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { inspectTree } from '@/lib/ui/inspect';
import { nodeRegistry } from '@/lib/ui/registry';
/**
* The page, as an outline.
*
* Everything shown here comes from `inspectTree` — the same inventory Owliver
* reads before it resolves "the recent hiring timeline". There is no second
* description of a page anywhere: if the editor can see a node, so can the
* conversation, and vice versa.
*
* **Hidden nodes are listed.** A hidden node is still in the tree and still
* addressable; the renderer skips it and nothing else does. Leaving it out here
* would make the editor the one place a person could not undo a hide.
*/
export function TreePanel({ tree, selectedId, onSelect, registry = nodeRegistry }) {
const nodes = inspectTree(tree, { registry });
if (!nodes.length) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-caption text-ink-3">
This page has no addressable sections yet.
</p>
);
}
/* Depth from parentage rather than from a nested walk, so the flat inventory
stays the one source and this only decides indentation. */
const depthOf = (node) => {
let depth = 0;
let cursor = node;
while (cursor?.parent) {
cursor = nodes.find((n) => n.id === cursor.parent);
depth += 1;
}
return depth;
};
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{nodes.map((node) => {
const selected = node.id === selectedId;
return (
<li key={node.id}>
<button
type="button"
onClick={() => onSelect(node.id)}
aria-current={selected ? 'true' : undefined}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors',
selected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle'
)}
>
<span style={{ paddingLeft: `${depthOf(node) * 14}px` }} className="flex min-w-0 flex-1 items-center gap-2">
<span className={cn('truncate text-body-sm', node.hidden ? 'text-ink-4' : 'text-ink-1')}>
{node.title || node.label}
</span>
{/* Provenance. A person needs to know which panels came from a
definition they could switch off, and which are the page. */}
{node.origin === 'skill' && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-1.5 py-0.5 text-[10px] font-semibold text-krow-blue">
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
Skill
</span>
)}
{node.origin === 'user' && (
<span className="shrink-0 rounded-full bg-surface-sunken px-1.5 py-0.5 text-[10px] font-semibold text-ink-3">
Added
</span>
)}
</span>
<span className="shrink-0 font-mono text-[10px] text-ink-4">{node.type}</span>
{node.hidden
? <EyeOff className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-label="Hidden" />
: <Eye className="h-3.5 w-3.5 shrink-0 text-ink-4/40" aria-hidden="true" />}
</button>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { RotateCcw, SlidersHorizontal, Undo2 } from 'lucide-react';
import { Button } from '@/components/ds';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { inspectTree } from '@/lib/ui/inspect';
import { nodeRegistry } from '@/lib/ui/registry';
import { TreePanel } from './TreePanel';
import { NodeInspector } from './NodeInspector';
import { NodePicker } from './NodePicker';
/**
* The visual editor.
*
* It is a *client* of the UI system, not a second implementation of it. Every
* control it draws ends in one call — `propose(op)` on the editing session —
* with an operation object of exactly the shape Owliver produces for the same
* change. From there the two are indistinguishable: same validation, same
* preview merge, same Apply, same `preferences.uiLayouts`.
*
* That is the whole architecture:
*
* editor / Owliver → operation → propose → validate → preview
* ↓ Apply
* preferences.uiLayouts
*
* There is no page in this file, no component name, and no branch on what a
* node is. What can be done to the selected node comes from its registration;
* what can be added comes from the registry and the closed data vocabulary;
* what it is showing comes from the tree. A page that migrates tomorrow is
* editable tomorrow with nothing here changed.
*/
export function UiEditor({ registry = nodeRegistry }) {
const editing = useUiEditing();
const [open, setOpen] = React.useState(false);
const [selectedId, setSelectedId] = React.useState(null);
/* Rendered only where a page has opted into composition. A page that has not
is not broken; it simply has nothing to arrange. */
if (!editing) return null;
const {
tree, propose, discard, apply, undo, reset,
previewing, customised, saving, problems, skipped,
/* The page this session belongs to. Handed to the picker so what can be
added here is decided by the registry rather than by the picker being
shown everything that exists. */
page,
} = editing;
const nodes = inspectTree(tree, { registry });
const selected = nodes.find((node) => node.id === selectedId) || null;
/* A node's own container, for the reorder buttons and for the picker. */
const siblings = selected ? nodes.filter((node) => node.parent === selected.parent) : [];
const parent = selected?.container ? selected : nodes.find((n) => n.id === selected?.parent) || null;
/**
* The one door out of this component.
*
* Everything the panels do arrives here as an operation and goes straight to
* the session. Nothing is applied, nothing is stored, and nothing is
* validated locally — `propose` refuses what cannot be kept and the refusal
* is shown below.
*/
const operate = (op) => {
const result = propose(op);
/* A removed node cannot stay selected; a replaced one keeps its id. */
if (result.ok && op.op === 'remove') setSelectedId(null);
};
return (
<div data-ui-controls="editor" className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
size="xs"
variant={open ? 'default' : 'outline'}
shape="rounded"
onClick={() => setOpen((v) => !v)}
>
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
Customise layout
</Button>
{/* The preview bar. Unsaved and saved have to be told apart at a
glance, because the whole promise is that nothing is kept until
somebody says so. */}
{(previewing || customised) && (
<div className="flex flex-wrap items-center gap-2">
{previewing && (
<span className="rounded-full bg-krow-blue-tint px-2 py-0.5 text-caption font-semibold text-krow-blue">
Previewing — not saved yet
</span>
)}
{!previewing && customised && (
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold text-ink-3">
Saved layout
</span>
)}
{previewing && (
<>
<Button size="xs" shape="rounded" loading={saving} onClick={apply}>Apply</Button>
<Button size="xs" variant="outline" shape="rounded" onClick={discard}>Discard</Button>
</>
)}
<Button size="xs" variant="ghost" shape="rounded" onClick={undo}>
<Undo2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Undo
</Button>
{customised && (
<Button size="xs" variant="ghost" shape="rounded" onClick={reset}>
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Reset
</Button>
)}
</div>
)}
</div>
{problems.length > 0 && (
<p className="text-caption text-destructive">{problems[0].message}</p>
)}
{skipped.length > 0 && (
<p className="text-caption text-ink-4">
{skipped.length} saved change{skipped.length === 1 ? '' : 's'} no longer apply to this page.
</p>
)}
{open && (
<div className="grid gap-3 rounded-xl border border-border bg-surface-subtle p-3 lg:grid-cols-2">
<div className="space-y-3">
<TreePanel tree={tree} selectedId={selectedId} onSelect={setSelectedId} registry={registry} />
<NodePicker tree={tree} parent={parent} page={page} onAdd={operate} registry={registry} />
</div>
<div className="rounded-xl border border-border bg-surface p-3">
{selected
? <NodeInspector node={selected} siblings={siblings} page={page} onOperate={operate} registry={registry} />
: (
<p className="text-caption text-ink-3">
Choose a section on the left to see what can be changed about it.
</p>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { freeNodeId } from '@/lib/ui/node';
import { dataSourceLabel } from '@/lib/skills/surfaces';
/**
* The operations the editor's controls stand for.
*
* Pure, and separate from the components, for one reason: it is the claim that
* the editor and Owliver do the same thing, and a claim buried inside a click
* handler cannot be checked. Here it can — `hideOp(node)` and the operation
* `matchUiEdit('hide the recent hiring timeline')` produces are the same object,
* and a test says so.
*
* Nothing here validates or applies. Every one of these is handed to
* `propose()` on the editing session, which is the single gate both surfaces
* pass through.
*/
/** Hide a visible node, or show a hidden one. */
export const hideOp = (node) => ({ op: 'hide', target: node.id, hidden: !node.hidden });
/** Set a node's hidden state explicitly, which is what a conversation says. */
export const visibilityOp = (node, hidden) => ({ op: 'hide', target: node.id, hidden });
/**
* Move a node one place within its own container.
*
* A reorder of the whole sibling list rather than a `move`, because that is
* what the button means: nothing changes parent, the order around it changes.
* The same shape Owliver produces for "move the timeline above the notice".
*/
export function nudgeOp(node, siblings, delta) {
const order = siblings.map((s) => s.id);
const from = order.indexOf(node.id);
const to = from + delta;
if (from < 0 || to < 0 || to >= order.length) return null;
order.splice(to, 0, ...order.splice(from, 1));
return { op: 'reorder', parent: node.parent ?? null, order };
}
/** Put a node at an explicit index among its siblings. */
export function reorderOp(node, siblings, index) {
const order = siblings.map((s) => s.id);
const from = order.indexOf(node.id);
if (from < 0 || index < 0 || index >= order.length) return null;
order.splice(index, 0, ...order.splice(from, 1));
return { op: 'reorder', parent: node.parent ?? null, order };
}
/** Turn a node into another registered type. */
export const replaceOp = (node, type) => ({ op: 'replace', target: node.id, type });
/** Remove a node the person added. */
export const removeOp = (node) => ({ op: 'remove', target: node.id });
/**
* Change one declared property.
*
* An empty value becomes `null`, which the engine reads as "unset" — the only
* way to clear a property, and the reason a blank field is not the same as a
* field nobody touched.
*/
export const propOp = (node, key, value) => ({
op: 'update', target: node.id, props: { [key]: value === '' || value === undefined ? null : value },
});
/** Change one layout value, from the closed vocabulary. */
export const layoutOp = (node, key, value) => ({
op: 'update', target: node.id, layout: { [key]: value === '' || value === undefined ? null : value },
});
/**
* Change how a node presents itself, from the closed vocabulary.
*
* The same `update` operation everything else here produces — the editor has no
* mutation of its own, and this is the shape Owliver emits for the same words.
*/
export const presentationOp = (node, key, value) => ({
op: 'update',
target: node.id,
presentation: { [key]: value === '' || value === undefined ? null : value },
});
/**
* Add a node of a registered type, optionally bound to a reading.
*
* The id is generated by the shared helper, so a node added here and a node
* added by Owliver get their addresses from one rule.
*/
export function addOp(tree, parent, type, source = null) {
return {
op: 'add',
parent: parent?.id ?? null,
node: {
id: freeNodeId(tree, type),
type,
...(source ? { data: { source }, props: { title: dataSourceLabel(source) } } : {}),
},
};
}

View File

@@ -0,0 +1,171 @@
import React from 'react';
import { useUiLayouts } from '@/lib/krowHooks';
import { useSkillSections } from '@/components/skills/SkillSurface';
import { skillNodesByPlacement } from '@/lib/ui/skillNodes';
import { composePage } from '@/lib/ui/composition';
import { applyOperation } from '@/lib/ui/operations';
import { clearOps, emptyPatch, popOp, pushOp } from '@/lib/ui/patch';
import { nodeRegistry } from '@/lib/ui/registry';
/**
* Preview, Apply, and the line between them.
*
* Three states of a UI change live here, and keeping them apart is the whole
* job of this module:
*
* - **Preview** is in React state. It is what the person is trying. It never
* reaches the network, and a reload discards it.
* - **Saved** is the person's own stored operation list, read from and
* written to their account preferences. It survives a reload and a new
* session.
* - **Source** — the page's registered composition and the Markdown skill
* files — is never written by anything here. Not on preview, not on apply.
*
* A proposed operation is validated *before* it becomes a preview, so a change
* that could not be saved is never shown as though it could. Preview and apply
* then run the identical merge through `composePage` and the identical
* renderer, which is what makes a preview honest: there is no second code path
* for the applied state that could disagree with it.
*/
const UiEditingContext = React.createContext(null);
/** The editing session for the page around it. Null outside a provider. */
export const useUiEditing = () => React.useContext(UiEditingContext);
export function UiEditingProvider({ page, role = null, registry = nodeRegistry, children }) {
const { layouts, save, saving } = useUiLayouts();
/**
* What the person is trying, not yet theirs.
*
* Held per page so that navigating away and back does not carry an
* unfinished experiment onto a different surface.
*/
const [preview, setPreview] = React.useState(() => emptyPatch(page));
const [problems, setProblems] = React.useState([]);
/* A page change is a new editing session. Anything unsaved was about the page
that is no longer on screen. */
React.useEffect(() => {
setPreview(emptyPatch(page));
setProblems([]);
}, [page]);
const saved = layouts[page] || emptyPatch(page);
/**
* What this page's definitions contribute, as nodes.
*
* Resolved here because it needs the account's custom skills and disabled
* list, and handed to `composePage` so that module stays a pure function.
* The result is that a Board skill's card is a node in the same tree as the
* page's own sections — addressable by the same operations, and hidden or
* moved by a patch rather than by editing the definition.
*/
const sections = useSkillSections(page);
const skillNodes = React.useMemo(() => skillNodesByPlacement(sections), [sections]);
/**
* The tree on screen: what the application ships, with what the person saved,
* with what they are trying, in that order.
*/
const composed = React.useMemo(
() => composePage(page, { patch: saved, preview, registry, role, skillNodes }),
[page, saved, preview, registry, role, skillNodes]
);
/**
* Try an operation.
*
* Validated against the tree as it currently stands — saved changes included
* — so an operation is judged against what the person is actually looking at.
* A refusal returns the reasons and changes nothing; there is no partially
* applied preview.
*/
const propose = React.useCallback((op) => {
const result = applyOperation(composed.tree, op, { registry, role });
if (!result.ok) {
setProblems(result.problems);
return result;
}
setProblems([]);
setPreview((current) => pushOp(current, op));
return result;
}, [composed.tree, registry, role]);
/** Throw the experiment away. Nothing was stored, so nothing is undone. */
const discard = React.useCallback(() => {
setPreview(emptyPatch(page));
setProblems([]);
}, [page]);
/**
* Keep it.
*
* The preview's operations are appended to what was already saved and written
* as one list. The preview is only cleared once the write resolves, so a
* failed save leaves the person looking at the change they asked for rather
* than watching it disappear with an error beside it.
*/
const apply = React.useCallback(async () => {
if (!preview.ops.length) return { ok: true, saved };
const next = { ...saved, page, ops: [...saved.ops, ...preview.ops], updatedAt: new Date().toISOString() };
const result = await save(page, next);
if (result?.persisted === false) {
setProblems([{ at: null, message: result.error || 'That change could not be saved.' }]);
return { ok: false, saved };
}
setPreview(emptyPatch(page));
return { ok: true, saved: next };
}, [preview, saved, page, save]);
/**
* Undo one step.
*
* The most recent thing first: an unsaved operation if there is one, and only
* then a saved one. Undoing a saved change is a write, because the saved list
* is the record of what the person chose.
*/
const undo = React.useCallback(async () => {
if (preview.ops.length) {
setPreview((current) => popOp(current));
return { ok: true };
}
if (!saved.ops.length) return { ok: true };
await save(page, popOp(saved));
return { ok: true };
}, [preview, saved, page, save]);
/** Back to the page as the application ships it. Clears both tiers. */
const reset = React.useCallback(async () => {
setPreview(emptyPatch(page));
setProblems([]);
if (saved.ops.length) await save(page, clearOps(saved));
return { ok: true };
}, [page, saved, save]);
const value = React.useMemo(() => ({
page,
tree: composed.tree,
/* Operations that no longer apply — a saved change naming a section a
release has since removed. Surfaced so a page can say so quietly rather
than leaving the person wondering why nothing happened. */
skipped: composed.skipped,
saved,
preview,
problems,
previewing: preview.ops.length > 0,
customised: saved.ops.length > 0,
saving,
propose,
discard,
apply,
undo,
reset,
}), [
page, composed, saved, preview, problems, saving, propose, discard, apply, undo, reset,
]);
return <UiEditingContext.Provider value={value}>{children}</UiEditingContext.Provider>;
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
/**
* One node's blast radius.
*
* A section is a component like any other, and a component can throw. Without
* this, one that did took the entire application down: adding Hired History's
* chronology to Candidates Analysis — which the picker offered, and which the
* registry now refuses — left the section destructuring `hires` and `filtered`
* from a render context that publishes neither, and `undefined.length` unmounted
* the whole tree to a white screen with no way back but a reload.
*
* The scope fix means that particular node can no longer be placed there. This
* exists because that was never the only way to get here: a page can rename
* what it publishes, a release can change a section's data shape, and a saved
* layout is replayed months after it was made. A layout a person saved must
* never be able to cost them the page.
*
* So a node that throws renders as a node that could not be drawn — in place,
* named, and still in the tree, so the editor can still select it and the
* conversation can still hide or remove it. Everything around it keeps working.
*
* Deliberately not a retry: the same props render the same failure, and a
* boundary that re-throws in a loop is worse than one that stops. It resets when
* the node it is holding changes, which is what makes removing the broken node
* put the page right without a reload.
*/
export class UiNodeBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { failed: false };
}
static getDerivedStateFromError() {
return { failed: true };
}
/**
* Reset when the node changes.
*
* Without this, hiding or removing a failed node would leave the boundary
* latched and the placeholder on screen — the fix applied, and invisible.
*/
static getDerivedStateFromProps(props, state) {
if (state.failed && state.forNode !== props.node?.id) return { failed: false, forNode: props.node?.id };
return state.forNode === props.node?.id ? null : { ...state, forNode: props.node?.id };
}
componentDidCatch(error) {
/* The node, not just the stack: the stack names React, and what a person
debugging this needs is which section and which page. */
// eslint-disable-next-line no-console
console.error(`[ui] node "${this.props.node?.id}" (${this.props.node?.type}) failed to render`, error);
}
render() {
if (!this.state.failed) return this.props.children;
const { node } = this.props;
return (
<section
data-ui-node={node?.id}
data-ui-type={node?.type}
data-ui-failed="true"
className="rounded-2xl border border-dashed border-border bg-surface-subtle p-4"
>
<p className="text-body-sm font-semibold text-ink-2">
{node?.props?.title || 'This section could not be shown'}
</p>
<p className="mt-0.5 text-caption text-ink-4">
It is still on the page and can be hidden or removed from Customise layout.
</p>
</section>
);
}
}

View File

@@ -0,0 +1,308 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { nodeRegistry } from '@/lib/ui/registry';
import { UiNodeBoundary } from './UiNodeBoundary';
import { composePage } from '@/lib/ui/composition';
import { useUiEditing } from './UiEditingProvider';
/**
* Draws a UI tree.
*
* One recursive renderer for every node type there will ever be. It looks a
* type up in the registry and renders the component that registration named —
* and that is the whole of its knowledge. There is no branch on a type here, no
* component name held as a string, no dynamic import, and no path by which
* configuration becomes code. A node names a key; the key was registered by a
* module the bundler resolved at build time; anything else renders nothing.
*
* That is what makes agent-authored UI safe. The worst a malformed or hostile
* configuration can do is name a type that does not exist, and the answer to
* that is an empty space — never an evaluated string, never injected markup.
*
* **It adds no markup of its own.** A node's component renders its own root and
* receives `attrs` to spread onto it, so a migrated page emits the elements it
* always did. The one exception is a type that declares `wrap`, for components
* that cannot forward unknown props; those get a bare `div` whose only purpose
* is to carry the node's identity.
*/
/**
* What a page hands its own sections.
*
* A page's built-in nodes need the page's own state — its filtered rows, its
* loading flag, its handlers — and threading that through the tree as props
* would make the renderer know what a page contains. So the page publishes one
* opaque bag and its sections read what they need out of it. The renderer never
* looks inside.
*
* Deliberately separate from `PageContext`, which is the panel's read-only view
* of a page's *records*. This is a page talking to its own parts.
*/
const UiRenderContext = React.createContext(null);
/** The bag the page published. Empty when a component is rendered outside a tree. */
export const useUiContext = () => React.useContext(UiRenderContext) || {};
/**
* The node currently being drawn.
*
* Lets a section know its own id without being passed it — which is what a
* future selection affordance needs, and what keeps the identity in one place
* rather than repeated in every registration.
*/
const UiNodeContext = React.createContext(null);
export const useUiNode = () => React.useContext(UiNodeContext);
/**
* The DOM attributes that make a node addressable.
*
* Two, not one: the id answers "which node is this", and the type answers "what
* is it" without a lookup. Both are `data-` attributes, so they carry no
* styling and cannot collide with anything the design system uses.
*/
export const nodeAttrs = (node) => ({
'data-ui-node': node.id,
'data-ui-type': node.type,
});
/**
* The grid a container arranges its children in.
*
* Only emitted when a node actually asks for columns. A node with no layout
* renders its children exactly as a page would have written them, which is what
* lets an existing page migrate without its markup changing.
*/
function layoutClass(layout) {
if (!layout?.columns) return null;
/* Written out rather than interpolated: Tailwind scans source text for class
names, and `grid-cols-${n}` is invisible to that scan and absent from the
build. One column is not a grid at all. */
const columns = {
1: null,
2: 'grid grid-cols-1 sm:grid-cols-2',
3: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid grid-cols-2 lg:grid-cols-4',
6: 'grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
12: 'grid grid-cols-2 sm:grid-cols-4 xl:grid-cols-6',
}[layout.columns] || 'grid grid-cols-1 sm:grid-cols-2';
const gap = { none: 'gap-0', sm: 'gap-2', md: 'gap-4', lg: 'gap-6' }[layout.gap] || 'gap-4';
return cn(columns, columns && gap);
}
/**
* The margin a node asks for above or below itself.
*
* Written out rather than interpolated, for the same reason the column classes
* are: Tailwind scans source text, and a class assembled at run time is absent
* from the build. Four steps, each a class the app already uses.
*/
export function spacingClasses(layout) {
/**
* Marked important, and that is not a shortcut.
*
* Pages stack their sections with `space-y-*`, which Tailwind implements as
* `.space-y-6 > :not([hidden]) ~ :not([hidden]) { margin-top: … }` — two
* classes and a pseudo-class, so it outranks a plain `mt-6` on the child no
* matter which is written last. The class landed on the element, the computed
* margin never moved, and the page looked identical: exactly the failure this
* whole change exists to end, one level further down.
*
* `none` is therefore a real value rather than the absence of one. Asking for
* no space above has to be able to say so, or the container's default is
* simply unopposable.
*
* Still a closed set: four steps, each written out, each a class Tailwind can
* see in this file. There is no path here from a stored value to arbitrary
* CSS — an unknown value maps to nothing.
*/
const before = {
none: '!mt-0', sm: '!mt-2', md: '!mt-4', lg: '!mt-6',
}[layout?.spacingBefore];
const after = {
none: '!mb-0', sm: '!mb-2', md: '!mb-4', lg: '!mb-6',
}[layout?.spacingAfter];
return cn(before, after) || undefined;
}
/**
* Everything a node's own layout asks of the element it is drawn as.
*
* `layoutClass` above is the other half and answers a different question: it is
* the grid a *container* arranges its children in. This is what a node asks for
* *itself* — the margin above and below it, how many columns of its parent's
* grid it occupies, and how it sits in the row.
*
* It existed only as spacing, and only one component ever called it. Everything
* else — the nine readings a person can add, and every section a definition
* draws — took `layout` as a prop and dropped it, so an operation validated,
* changed the tree, persisted, and produced no visible change at all. A layout
* value that cannot be seen is worse than one that is refused.
*
* **Every class is written out.** Tailwind scans source text, so a class
* assembled at run time is absent from the build and would silently do nothing —
* the same failure in a new place. That is also what keeps this closed: these
* are the only classes a layout value can ever produce, there is no path from
* configuration to arbitrary CSS, and a value outside the vocabulary maps to
* nothing rather than to itself.
*/
export function layoutClasses(layout) {
const span = {
1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4',
5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8',
9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
}[layout?.span];
const align = {
start: 'self-start', center: 'self-center', end: 'self-end', stretch: 'self-stretch',
}[layout?.align];
return cn(spacingClasses(layout), span, align) || undefined;
}
/**
* The panel a node draws itself as.
*
* The closed half of variant and density: two short maps, every class written
* out so Tailwind can see it, and nothing that reads a value from configuration
* into a class name. A value outside the vocabulary yields the default, so the
* worst a malformed patch can do is look ordinary.
*
* `default` and `comfortable` reproduce **exactly** the panel every reading
* section has always drawn — that is what lets this ship without changing a
* single existing page, and what the migration baselines check.
*
* Returned as parts rather than one string because a component composes them
* with its own classes and needs to control the order.
*/
export function presentationClasses(presentation) {
const variant = {
default: 'border-border bg-surface shadow-xs',
subtle: 'border-border bg-surface-subtle shadow-none',
emphasis: 'border-krow-blue/40 bg-krow-blue-tint/30 shadow-md ring-1 ring-krow-blue/20',
}[presentation?.variant] || 'border-border bg-surface shadow-xs';
const density = {
comfortable: { padding: 'p-5', gap: 'mb-3', title: 'text-body' },
compact: { padding: 'p-3', gap: 'mb-1.5', title: 'text-body-sm' },
}[presentation?.density] || { padding: 'p-5', gap: 'mb-3', title: 'text-body' };
return { variant, ...density };
}
/** One node: its component, its identity, and its children if it holds any. */
function UiNode({ node, registry }) {
const entry = registry.get(node.type);
/* Hidden is a user's decision and is honoured before anything else, so a
hidden node costs nothing to render. */
if (node.hidden) return null;
/* Validation refuses an unknown type long before a tree is rendered, so
reaching here means the registry and a stored patch have drifted — a type
removed by a release, most likely. Render nothing rather than throw: one
stale node must not take the page down. */
if (!entry) return null;
const Component = entry.component;
const attrs = nodeAttrs(node);
const children = entry.container
? <UiTree nodes={node.children} registry={registry} layout={node.layout} />
: null;
const drawn = (
<Component
node={node}
attrs={entry.wrap ? {} : attrs}
layout={node.layout}
presentation={node.presentation}
{...node.props}
>
{children}
</Component>
);
/* Wrapped so a section that throws costs the page that section, not the
application. See `UiNodeBoundary`. */
return (
<UiNodeContext.Provider value={node}>
<UiNodeBoundary node={node}>
{entry.wrap ? <div {...attrs}>{drawn}</div> : drawn}
</UiNodeBoundary>
</UiNodeContext.Provider>
);
}
/**
* A list of nodes.
*
* Renders a bare fragment unless a layout was asked for, so the nodes sit
* directly inside whatever container the page already had — and a page that
* migrates its sections into the tree keeps the spacing it always had.
*/
function UiTree({ nodes, registry, layout = null }) {
const grid = layoutClass(layout);
const drawn = (nodes || []).map((node) => (
<UiNode key={node.id} node={node} registry={registry} />
));
return grid ? <div className={grid}>{drawn}</div> : <>{drawn}</>;
}
/**
* Render a composed page.
*
* `context` is the bag the page publishes for its own sections. `nodes` is the
* merged tree — built-ins with the user's saved changes and any preview already
* folded in by `composePage`, so this component neither reads storage nor knows
* that a patch exists.
*/
export function UiTreeRenderer({ nodes = [], context = null, registry = nodeRegistry }) {
/* Identity-stable across renders so a page's sections do not remount every
time the page re-renders for an unrelated reason. */
const value = React.useMemo(() => context || {}, [context]);
return (
<UiRenderContext.Provider value={value}>
<UiTree nodes={nodes} registry={registry} />
</UiRenderContext.Provider>
);
}
/**
* One node of a page's tree, drawn where the page puts it.
*
* For a page that is only **partly** composed. Positions is the case this
* exists for: its two page-level extension slots sit at fixed points in a large
* hand-written layout, and migrating that whole layout is a separate piece of
* work. Until then the page keeps its own JSX and renders the composed nodes it
* does have, each in the place it has always been.
*
* The consequence is worth being clear about: nodes anchored this way can be
* hidden, and their children moved and reordered, but reordering the page's
* *roots* has nowhere to happen — there is no single container drawing them in
* sequence. A fully composed page has no such limit.
*
* `page` is named rather than inferred so the component works during a baseline
* capture, where no editing session is mounted.
*/
export function UiNodeSlot({ page, id, context = null, registry = nodeRegistry }) {
const editing = useUiEditing();
const node = React.useMemo(() => {
const tree = editing?.page === page && editing.tree
? editing.tree
: composePage(page, { registry }).tree;
return (tree || []).find((candidate) => candidate.id === id) || null;
}, [editing, page, id, registry]);
if (!node || node.hidden) return null;
/* A container with nothing in it draws nothing — the rule `SkillSurface` has
always followed, and the reason an empty extension point costs no space on
the pages that have no definitions for it. */
const entry = registry.get(node.type);
if (entry?.container && !node.children?.length) return null;
return <UiTreeRenderer nodes={[node]} context={context} registry={registry} />;
}

View File

@@ -0,0 +1,176 @@
import React from 'react';
import {
Bar, BarChart, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { cn } from '@/lib/utils';
import { AXIS_PROPS, CHART_COLORS, CHART_TONES } from '@/components/ds/ChartContainer';
import { SECTION_TYPES } from '@/lib/skills/surfaces';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
import { registerNodeType } from '@/lib/ui/registry';
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
/**
* Two ways of drawing a series: as bars, and as parts of a whole.
*
* These are the first node types that are **not** skill section types. The nine
* readings exist in `surfaces.js` because a Markdown definition may name them;
* chart and pie are named by nobody but this registry, so adding them here adds
* nothing to the MD vocabulary, obliges no change in the Go parser, and cannot
* drift from its conformance oracle. A person reaches them by turning an
* existing node into one.
*
* **Nothing is transformed and nothing is invented.** A `flow` or a `stats`
* reading already resolves to `{ steps: [{ id, label, value }] }` — a labelled
* numeric series, which is precisely what a bar chart and a pie chart each
* need. So these draw the same payload the Flow section draws, from the same
* resolver, keyed by the same source. There is no adapter layer here because
* there is nothing to adapt.
*
* The consequence is the compatibility rule, and it is the existing one: a
* source can become a chart exactly when it declares `flow` or `stats`, decided
* by `sourceSupportsShape` like every other replacement. No new shape, no new
* vocabulary, no per-source table.
*/
/** The series a reading resolved to, in the one shape both charts read. */
function useSeries(node) {
const context = useSkillDataContext(null);
const section = React.useMemo(() => ({
id: node.id,
/* `flow` because that is the shape whose payload these draw. Resolvers are
keyed by source and never read this, but a section without a type is not
a section, and naming the shape it consumes keeps that honest. */
type: 'flow',
source: node.data?.source || '',
periods: node.data?.params?.periods || [],
limit: node.data?.params?.limit || null,
}), [node.id, node.data]);
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
const rows = (data.steps || data.items || [])
.map((step, i) => ({
id: step.id || `${i}`,
label: String(step.label || step.title || ''),
value: Number(step.value) || 0,
}))
/* A slice of nothing is not a slice, and a bar of nothing is a gap. Both
charts read real numbers or draw the empty note. */
.filter((row) => row.label);
return { data, rows };
}
/** How tall the plot is, and therefore how big a pie fits in it. */
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 160 : 240);
/** The panel both share — the same chrome every reading section draws. */
function ChartPanel({ node, attrs, layout, presentation, title, children, empty }) {
const look = presentationClasses(presentation);
const height = plotHeight(presentation);
return (
<section
{...attrs}
aria-label={title || undefined}
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
>
{title && (
<div className={look.gap}>
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
</div>
)}
{empty
? <p className="text-caption text-ink-3">{empty}</p>
: <div style={{ height }}>{children}</div>}
</section>
);
}
/** Bars, one per step, in the brand's own colour. */
function ChartNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
const { data, rows } = useSeries(node);
const empty = !rows.length ? (data.emptyNote || 'Nothing to chart yet.') : null;
return (
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis dataKey="label" {...AXIS_PROPS} interval={0} height={40} />
<YAxis {...AXIS_PROPS} allowDecimals={false} />
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
<Bar dataKey="value" fill={CHART_TONES.brand} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartPanel>
);
}
/** The same series as parts of a whole. */
function PieNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
const { data, rows } = useSeries(node);
const total = rows.reduce((sum, row) => sum + row.value, 0);
/* A pie of nothing is a blank disc, so an all-zero series is empty rather
than drawn — the reading has values, they are simply all zero. */
const empty = !rows.length
? (data.emptyNote || 'Nothing to chart yet.')
: (total === 0 ? 'Every value here is zero.' : null);
const height = plotHeight(presentation);
const radius = { outer: Math.round(height * 0.38) };
return (
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
{/* Centre and radii given explicitly, in pixels.
Percentage radii with no `cx`/`cy` produced sectors that existed in
the DOM, carried no fill and drew nothing — a blank panel that
every automated check called a success because the elements were
there. The one pie already in this repository does it this way,
and it works. */}
<Pie
data={rows}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
outerRadius={radius.outer}
isAnimationActive={false}
>
{rows.map((row, i) => (
<Cell key={row.id} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</ChartPanel>
);
}
/* The shapes whose payload both of these draw, read from the vocabulary rather
than written out, so a shape renamed there cannot leave these claiming one
that no longer exists. */
const SERIES_SHAPES = ['flow', 'stats'].filter((shape) => SECTION_TYPES.some((t) => t.id === shape));
for (const [type, label, summary, component] of [
['chart', 'Chart', 'A bar for each step in a reading.', ChartNode],
['pie', 'Pie', 'The same steps as parts of a whole.', PieNode],
]) {
registerNodeType({
type,
label,
summary,
component,
dataShapes: SERIES_SHAPES,
dataRequired: true,
propSchema: { title: { type: 'string', label: 'Title' } },
variants: VARIANT_VALUES,
/* Both densities change the plot height, which is the one thing a chart has
to give. Declared because they are honoured, not because they exist. */
densities: DENSITY_VALUES,
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
});
}

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SUPPORTED_SECTION_TYPES } from '@/lib/skills/surfaces';
import { layoutClasses } from './UiTreeRenderer';
import { registerNodeType } from '@/lib/ui/registry';
/* The nine reading components, registered from the skill vocabulary. Imported
here so one import gives a page every type it can offer. */
import './sectionNodeTypes';
/* The two visualisation types. Not skill sections — see the note in the file. */
import './chartNodeTypes';
/**
* Node types every page can use.
*
* Page-specific sections register beside their own page; this file is for the
* types that belong to no page in particular. Today that is one: the slot a
* skill definition renders into.
*/
/**
* A skill surface, as a node.
*
* This is the join between the two systems, and it is deliberately thin. A
* `ui:` block in a skill definition is still normalized by `uiConfig.js`,
* resolved by `dataResolver.js` and drawn by `SkillSurface` exactly as it is
* today — nothing about Board skills changes. What changes is that the *slot*
* is now a node, so a person can move the extension point up the page or hide
* it, using the same operations that move a built-in section.
*
* It renders **nothing of its own**. `SkillSurface` already returns `null` when
* no definition claims the placement, and that must stay true: a slot that
* rendered an empty wrapper would add a gap to every page it sits on, on every
* account that has authored no skills — which is nearly all of them. So this
* type does not declare `wrap`, and accepts having no DOM identity while it is
* empty over changing what an empty page looks like.
*/
/**
* The slot, as a container.
*
* It draws nothing of its own and holds no logic: the sections that belong to
* it are children in the tree, put there by `composePage`, and the renderer
* walks them like any other children. That is what makes a Board skill's card
* addressable — it is a node, not something a component conjured up while
* rendering.
*
* Empty means **nothing**, not an empty box. `SkillSurface` has always returned
* `null` where no definition claims a placement, and every page that has not
* migrated still calls it directly. A slot that rendered a wrapper regardless
* would put a gap on every page of every account that has authored no skills,
* which is nearly all of them.
*/
function SkillSurfaceNode({ node, children, layout }) {
if (!node.children?.length) return null;
/* The same spacing `SkillSurface` puts between sections, plus whatever margin
the page's composition asked for — which is how a slot keeps the exact
separation it had before it became a node. */
return <div className={cn('space-y-4', layoutClasses(layout))}>{children}</div>;
}
registerNodeType({
type: 'skill-surface',
label: 'Skill sections',
summary: 'Where definitions authored for this page render.',
component: SkillSurfaceNode,
container: true,
/* Only readings may sit in a slot — the nine the skill format already allows.
Derived from the vocabulary rather than listed, so the two cannot drift. */
accepts: SUPPORTED_SECTION_TYPES,
/* Moving and hiding the slot is meaningful; replacing it with a chart is not,
and neither is deleting the only way a page can be extended. Its children
are separately addressable and carry their own capabilities. */
capabilities: ['move', 'hide', 'reorder'],
/**
* Never something a person adds.
*
* A slot exists because a page offered an extension point at a particular
* spot, and a second one conjured up by a picker would anchor nothing — no
* definition names it, so it would render empty forever. It was offered and
* then refused with "'Skill sections' cannot be added to", which is the
* product asking a question it already knew the answer to. It is a container
* and an anchor; the two are not the same claim.
*/
addable: false,
/**
* Which slot this is.
*
* A page mounts several, and they all share one label — so an outline listed
* "Skill sections" twice with nothing to choose between them, and "hide the
* skill sections" had no answer. The placement is the only thing that
* distinguishes them and it is already on the node, so it is what they are
* called: "Skill sections (after header)".
*/
describe: (node) => {
const placement = String(node?.props?.placement || '').trim();
if (!placement) return 'Skill sections';
return `Skill sections (${placement.replace(/-/g, ' ')})`;
},
propSchema: {
page: { type: 'string', required: true, label: 'Page' },
placement: { type: 'string', required: true, label: 'Placement' },
},
});

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { Sparkles } from 'lucide-react';
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { usePageAction } from '@/components/ai-assistant/PageContext';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { SECTION_TYPES, dataSourceFor } from '@/lib/skills/surfaces';
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
import { registerNodeType } from '@/lib/ui/registry';
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
import { cn } from '@/lib/utils';
/**
* The nine reading components, registered as node types.
*
* These are the same components a Board skill draws with — `SECTION_COMPONENTS`
* from `SkillSections.jsx`, resolved through the same `resolveSkillData` and the
* same closed data vocabulary. Registering them here is what makes "add a card"
* and "change this to a table" mean something: a person can put one of the
* product's own readings on a page without authoring a skill for it.
*
* Nothing about skills changes. A `ui:` block still renders through
* `SkillSurface` exactly as before; this is a second consumer of the same
* renderer, which is the arrangement `SkillSections.jsx` was already built for —
* the page and the chat panel were the first two.
*
* **A node of these types cannot invent data.** It carries a data *binding*, not
* data: a source id from the closed vocabulary, resolved at render time against
* records the caller already has. A binding naming something that is not a
* source is refused by validation before it can be previewed, let alone saved.
*/
/**
* One reading, drawn.
*
* The `section` shape is assembled from the node rather than from a Markdown
* definition, but it is the same shape `normalizeSection` produces — which is
* why the resolver and the component take it without knowing which of the two
* built it.
*/
function ReadingNode({
node, attrs = {}, layout = null, presentation = null, title = null, description = null,
attribution = null, editable = false, type,
}) {
const Component = SECTION_COMPONENTS[type];
const context = useSkillDataContext(null);
const section = React.useMemo(() => ({
id: node.id,
type,
title: title || null,
description: description || null,
source: node.data?.source || '',
periods: node.data?.params?.periods || [],
limit: node.data?.params?.limit || null,
/* What the reading needs the page to have open. Read off the source, so a
node cannot claim a context its source never declared. */
context: dataSourceFor(node.data?.source)?.context,
editable: Boolean(editable),
}), [node.id, node.data, title, description, editable, type]);
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
/* How this section writes back, if the page is offering that write at all —
the same rule `SkillSurface` applies, so a section declared editable on a
page that does not own the data stays an honest read-out. */
const apply = usePageAction(section.editable ? section.source : null);
/* Validation refuses a binding-less reading long before this, so reaching here
without one means a stored patch outlived a vocabulary change. Draw nothing
rather than an empty panel with a title. */
/* The closed map, read once. Not a hook, so it sits with the other derived
values and changes nothing about when this component re-renders. */
const look = presentationClasses(presentation);
if (!Component || !node.data?.source) return null;
/**
* Two chromes, one component.
*
* A section contributed by a skill is drawn exactly as `SkillSurface` has
* always drawn it — same panel, same heading, same attribution pill — because
* moving a Board card into the node tree must not change how it looks. A node
* a person added has no skill to attribute, so it gets the plain panel. The
* difference is a property, not a branch on where the node came from.
*/
return (
<section
{...attrs}
aria-label={section.title || attribution || undefined}
/* The panel this draws, as its own presentation describes it, plus
whatever its layout asks for. Both helpers return the existing values
when a node asks for nothing, so a section nobody has customised
renders exactly the markup it did before — which is what the migration
baselines check. */
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
>
{(section.title || attribution) && (
<div className={cn(look.gap, 'flex flex-wrap items-start justify-between gap-2')}>
<div className="min-w-0">
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>
{section.title}
</h3>
{section.description && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{section.description}</p>
)}
</div>
{attribution && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-2 py-0.5 text-[10px] font-semibold text-krow-blue">
<Sparkles className="h-3 w-3" aria-hidden="true" />
{attribution}
</span>
)}
</div>
)}
<Component data={data} section={section} onApply={apply} />
</section>
);
}
for (const { id, label, summary } of SECTION_TYPES) {
registerNodeType({
type: id,
label,
summary,
/* Bound per type so the component does not have to read its own name back
out of the node it was handed. */
component: (props) => <ReadingNode {...props} type={id} />,
/* All nine draw the same panel through `ReadingNode`, so all nine can
honour the whole vocabulary. Declared from it rather than listed, so a
value added to the product reaches every reading without an edit here —
and a value removed cannot be left behind claiming support. */
variants: VARIANT_VALUES,
densities: DENSITY_VALUES,
/* The one shape this component draws. Validation pairs it against what a
source can fill, so `table` accepts only sources that have a table in
them — the same rule `normalizeSection` already applies to a skill. */
dataShapes: [id],
dataRequired: true,
propSchema: {
title: { type: 'string', label: 'Title' },
description: { type: 'string', label: 'Description' },
/* The skill that contributed this section, when one did. Carried as a
property rather than inferred from `origin`, so the renderer stays
ignorant of provenance. */
attribution: { type: 'string', label: 'Contributed by' },
editable: { type: 'boolean', label: 'Editable' },
},
/* Everything, because unlike a built-in page section these are nodes a
person put there: they can be removed as well as hidden. */
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
});
}