import * as React from 'react'; import { ArrowDownRight, ArrowUpRight, Check, TriangleAlert } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; import { ProgressBar, toneForScore } from '@/components/ds/Progress'; /** * 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, each in a tinted card so severity is scannable. */ const InsightsBlock = React.memo(({ block }) => (
    {block.items.map((item, i) => (

    {item.title}

    {item.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'; const RENDERERS = { text: TextBlock, 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 }) => (
    {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 };