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 }) => (

)); TextBlock.displayName = 'TextBlock'; const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (

{block.text}

{block.sub &&

{block.sub}

}
)); HeadingBlock.displayName = 'HeadingBlock'; /** Headline figures. Two columns keeps the numbers large in a 380px panel. */ const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
{block.items.map((item) => (

{item.label}

{item.value} {typeof item.delta === 'number' && item.delta !== 0 && ( 0 ? 'text-success' : 'text-destructive' )}> {item.delta > 0 ? )}
{item.sub &&

{item.sub}

}
))}
)); 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 }) => ( )); StatusBlock.displayName = 'StatusBlock'; const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
{block.items.map((item) => (
{item.label} {item.weight != null && {item.weight}%} {item.value}
))}
)); MetersBlock.displayName = 'MetersBlock'; /** * A table. Scrolls horizontally inside its own container so a wide comparison * never widens the panel. */ const TableBlock = React.memo(/** @param {any} props */ ({ block }) => { const cell = (value) => { if (value == null || value === '') return —; if (typeof value === 'object') { if (value.badge) { return {value.badge}; } return ( {value.value} ); } return {value}; }; return (
{block.caption && } {block.columns.map((column) => ( ))} {block.rows.map((row, i) => ( {block.columns.map((column, ci) => ( ))} ))}
{block.caption}
{column.label}
{cell(row[column.key])}
); }); TableBlock.displayName = 'TableBlock'; /** Stage progression, widths relative to the largest stage. */ const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => { const max = Math.max(...block.steps.map((s) => s.count), 1); return (
{block.steps.map((step) => (
{step.label} {step.rate != null && {step.rate}%} {step.count}
0 ? 4 : 0)}%` }} />
))}
); }); FunnelBlock.displayName = 'FunnelBlock'; const ListBlock = React.memo(/** @param {any} props */ ({ block }) => { const Tag = block.ordered ? 'ol' : 'ul'; return ( {block.items.map((item, i) => (
  • {block.ordered ? ( {i + 1} ) : (
  • ))}
    ); }); 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 }) => (
    {block.items.map((item, i) => { const surface = cn( 'block w-full text-left rounded-xl border px-3 py-2.5', TONE_SURFACE[item.tone] || TONE_SURFACE.neutral ); const interactive = cn( surface, 'transition-colors hover:border-krow-blue/50', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50' ); const asksOwliver = Boolean(item.prompt && onPrompt); const actionable = asksOwliver || Boolean(item.to); const body = ( <>

    {item.title} {actionable &&

    {item.body && (

    )} {item.hint && (

    {item.hint}

    )} ); /* 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) => (
    1. {i + 1}

      {item.title}

      {item.body && (

      )}
    2. ))}
    )); ActionsBlock.displayName = 'ActionsBlock'; const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
    {block.items.map((item) => ( {item.label} ))}
    )); BadgesBlock.displayName = 'BadgesBlock'; const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
      {block.items.map((item, i) => (
    1. {item.title} {item.timestamp && ( {item.timestamp} )}
      {item.description && (

      {item.description}

      )}
    2. ))}
    )); TimelineBlock.displayName = 'TimelineBlock'; const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (

    )); 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 (

    {block.title}

    {block.summary && (

    {block.summary}

    )} {block.details?.length > 0 && (
    {block.details.map((d, i) => (
    {d.label}
    {d.value}
    ))}
    )} {/* Above the button, deliberately. */} {block.warnings?.length > 0 && (
      {block.warnings.map((w, i) => (
    • ))}
    )}
    {state === 'pending' ? ( <> ) : (

    )}
    ); }); ConfirmationBlock.displayName = 'ConfirmationBlock'; const RENDERERS = { text: TextBlock, confirmation: ConfirmationBlock, 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(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
    {blocks.map((block, i) => { const Renderer = RENDERERS[block.type]; if (!Renderer) return null; const hugsNext = block.type === 'heading' && i < blocks.length - 1; const isLast = i === blocks.length - 1; return (
    {/* The cursor trails the final block only while text is still arriving. */} {streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
    ); })}
    )); ResponseDocument.displayName = 'ResponseDocument'; export { toneForScore };