));
KpisBlock.displayName = 'KpisBlock';
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
const StatusBlock = React.memo(({ block }) => (
))}
);
});
ListBlock.displayName = 'ListBlock';
/**
* Findings, and — where the item carries one — a way to act on it.
*
* Two kinds of action, and the distinction is the point:
*
* `prompt` asks Owliver the next question, in place. The panel stays open,
* the thread keeps its history, and the reader never loses the
* position they were working on. This is the default for a
* recommended person: inspecting a candidate is part of the
* conversation, not a trip to another page.
* `to` leaves for a route the app already has, with the whole card as the
* target. Reserved for items whose only meaning is "open this".
* `action` a named link inside the card — `{ label, to }`. For an item that
* has *both* a conversation and a record: the card keeps asking
* Owliver, and the link is the one explicit way to leave. Reading a
* card can then never navigate by accident, which matters most where
* the body is an explanation worth reading.
*
* `hint` is for an item that can do neither — it says why, instead of looking
* clickable and doing nothing. An item may carry a hint *and* an action: the
* hint explains the record, the action opens it.
*/
const InsightsBlock = React.memo(({ block, onPrompt }) => (
)}
>
);
/* A named link cannot live inside a button, so when an item has both, the
card is a plain container: the readable area is its own button and the
action sits beside it. One click target each, never nested. */
if (item.action?.to) {
return (
{asksOwliver ? (
) : body}
{item.action.label}
);
}
if (asksOwliver) {
return (
);
}
if (item.to) {
return {body};
}
return
{body}
;
})}
));
InsightsBlock.displayName = 'InsightsBlock';
/** Recommended steps — numbered, because order is the recommendation. */
const ActionsBlock = React.memo(({ block }) => (
{block.items.map((item, i) => (
));
NoteBlock.displayName = 'NoteBlock';
/**
* A section a skill declared, drawn by the component the page uses for it.
*
* There is no chat-specific renderer for a skill's shapes, and there must not
* be: `SECTION_COMPONENTS` is the single table from a declared type to a
* component, and this block goes through it exactly as `SkillSurface` does. A
* definition that gains a new shape gains it in both places at once, and a
* shape the table does not carry renders nothing rather than something
* improvised.
*/
const SkillSectionBlock = React.memo(({ block }) => {
const section = block.section;
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
/**
* A settable answer is re-read, never replayed.
*
* An ordinary answer is a record of what was true when it was given, and the
* stored blocks are exactly right for that. A section offering *controls* is
* different: it is a live view of a value the reader can still change, from
* here or on the page behind the panel. Replaying the stored copy would show
* a weight of 25% next to a form that now says 30%, and the control would
* write the stale figure back.
*
* So an editable section resolves from the current context on every render,
* through the same resolver the page's own card uses. There is one value and
* one place it lives; this is a second window onto it, not a second copy.
*/
const live = Boolean(section?.editable);
const context = useSkillDataContext(null);
const data = React.useMemo(
() => (live && section ? resolveSkillData(section, context) : block.data),
[live, section, context, block.data]
);
const apply = usePageAction(live ? section?.source : null);
if (!Component) return null;
return (
);
});
SkillSectionBlock.displayName = 'SkillSectionBlock';
const RENDERERS = {
text: TextBlock,
skillSection: SkillSectionBlock,
heading: HeadingBlock,
kpis: KpisBlock,
status: StatusBlock,
meters: MetersBlock,
table: TableBlock,
funnel: FunnelBlock,
list: ListBlock,
insights: InsightsBlock,
actions: ActionsBlock,
badges: BadgesBlock,
timeline: TimelineBlock,
note: NoteBlock,
};
/**
* Renders a response document.
*
* Spacing is decided here rather than by the blocks, so any combination of
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt }) => (
{/* The cursor trails the final block only while text is still arriving. */}
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
)}