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 = /(\*\*[^*]+\*\*|_[^_]+_)/g; /** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is * carried by blocks, not by markup inside a paragraph. */ 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)}; } 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(({ block }) => (

)); TextBlock.displayName = 'TextBlock'; const HeadingBlock = React.memo(({ 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(({ 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(({ block }) => ( )); StatusBlock.displayName = 'StatusBlock'; const MetersBlock = React.memo(({ 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(({ 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(({ 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(({ 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(({ 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(({ block }) => (
      {block.items.map((item, i) => (
    1. {i + 1}

      {item.title}

      {item.body && (

      )}
    2. ))}
    )); ActionsBlock.displayName = 'ActionsBlock'; const BadgesBlock = React.memo(({ block }) => (
    {block.items.map((item) => ( {item.label} ))}
    )); BadgesBlock.displayName = 'BadgesBlock'; const TimelineBlock = React.memo(({ 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(({ 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(({ 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 }) => (
    {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 };