This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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's data. Check anything you act on.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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. */
|
||||
|
||||
177
src/components/ai-assistant/uiEdit.js
Normal file
177
src/components/ai-assistant/uiEdit.js
Normal 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".')
|
||||
);
|
||||
}
|
||||
@@ -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(), []);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
233
src/components/ui-editor/NodeInspector.jsx
Normal file
233
src/components/ui-editor/NodeInspector.jsx
Normal 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);
|
||||
90
src/components/ui-editor/NodePicker.jsx
Normal file
90
src/components/ui-editor/NodePicker.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/ui-editor/TreePanel.jsx
Normal file
87
src/components/ui-editor/TreePanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/components/ui-editor/UiEditor.jsx
Normal file
146
src/components/ui-editor/UiEditor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
src/components/ui-editor/ops.js
Normal file
99
src/components/ui-editor/ops.js
Normal 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) } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
171
src/components/ui-tree/UiEditingProvider.jsx
Normal file
171
src/components/ui-tree/UiEditingProvider.jsx
Normal 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>;
|
||||
}
|
||||
76
src/components/ui-tree/UiNodeBoundary.jsx
Normal file
76
src/components/ui-tree/UiNodeBoundary.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
308
src/components/ui-tree/UiTreeRenderer.jsx
Normal file
308
src/components/ui-tree/UiTreeRenderer.jsx
Normal 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} />;
|
||||
}
|
||||
176
src/components/ui-tree/chartNodeTypes.jsx
Normal file
176
src/components/ui-tree/chartNodeTypes.jsx
Normal 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'],
|
||||
});
|
||||
}
|
||||
102
src/components/ui-tree/nodeTypes.jsx
Normal file
102
src/components/ui-tree/nodeTypes.jsx
Normal 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' },
|
||||
},
|
||||
});
|
||||
155
src/components/ui-tree/sectionNodeTypes.jsx
Normal file
155
src/components/ui-tree/sectionNodeTypes.jsx
Normal 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'],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user