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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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