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
?
: }
{Math.abs(item.delta)}%
)}
{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 }) => (
{block.items.map((item) => (
-
{item.ok
?
: }
{item.label}
{item.value && (
{item.value}
)}
{item.note &&
{item.note}
}
{item.ok ? 'Passing' : 'Needs attention'}
))}
));
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.caption}}
{block.columns.map((column) => (
|
{column.label}
|
))}
{block.rows.map((row, i) => (
{block.columns.map((column, ci) => (
|
{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}
))}
);
});
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) => (
-
{i + 1}
{item.title}
{item.body && (
)}
))}
));
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) => (
-
{i < block.items.length - 1 && }
{item.title}
{item.timestamp && (
{item.timestamp}
)}
{item.description && (
{item.description}
)}
))}
));
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 };