import * as React from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownRight, ArrowRight, ArrowUpRight, Check, ChevronRight, TriangleAlert,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { usePageAction } from './PageContext';
/**
* Renderers for response blocks.
*
* Every block is drawn with the same tokens as the dashboard — the ink scale,
* the shared radius, the score-band colours — so a generated report looks like
* it belongs to Krow rather than to a chat library.
*
* Blocks are memoized individually: a streaming response re-renders on every
* snapshot, and settled blocks must not re-render with it.
*/
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
/**
* 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]);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return {part.slice(2, -2)};
}
if (part.startsWith('_') && part.endsWith('_')) {
return {part.slice(1, -1)};
}
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 {label};
return (
{label}
);
}
return part;
});
}
const TONE_TEXT = {
info: 'text-krow-blue',
success: 'text-success',
warning: 'text-warning',
risk: 'text-destructive',
neutral: 'text-ink-3',
};
const TONE_SURFACE = {
info: 'bg-krow-blue-tint border-krow-blue/15',
success: 'bg-success-muted border-success/15',
warning: 'bg-warning-muted border-warning/20',
risk: 'bg-destructive-muted border-destructive/15',
neutral: 'bg-surface-subtle border-border',
};
const TONE_BADGE = {
info: 'info', success: 'success', warning: 'warning', risk: 'destructive', neutral: 'neutral',
};
/* ── Individual blocks ──────────────────────────────────────────────────── */
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
));
KpisBlock.displayName = 'KpisBlock';
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
const StatusBlock = React.memo(/** @param {any} props */ ({ 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(/** @param {any} props */ ({ 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(/** @param {any} props */ ({ 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(/** @param {any} props */ ({ 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';
/**
* A write the agent has proposed. The only block a person can act on.
*
* Three things it must do, and they are all about not being clicked past:
*
* - **Say what will happen, in the server's words.** The title, summary and
* details are rendered as they arrived. A browser paraphrasing them would
* be describing a different act than the one the token authorises.
* - **Put warnings above the button.** A clash or an over-headcount is
* exactly what somebody is about to approve without noticing, and a warning
* underneath the decision is a warning read afterwards.
* - **Not pretend to be finished.** Once approved, the block stays visible
* and says so. Replacing it with a tick would lose what was agreed to.
*/
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
const [state, setState] = React.useState('pending');
const approve = React.useCallback(() => {
/* Guarded rather than debounced. The token is single-use server-side, so a
double click costs a confusing refusal rather than a duplicate write —
but a button that visibly does nothing the second time is kinder than
one that reports an error somebody caused by being quick. */
if (state !== 'pending') return;
setState('approved');
onConfirm?.(block);
}, [state, block, onConfirm]);
return (
{/* The cursor trails the final block only while text is still arriving. */}
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
)}