410 lines
15 KiB
JavaScript
410 lines
15 KiB
JavaScript
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 <strong key={i} className="font-semibold text-ink-1">{part.slice(2, -2)}</strong>;
|
|
}
|
|
if (part.startsWith('_') && part.endsWith('_')) {
|
|
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
|
}
|
|
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 }) => (
|
|
<p className="text-body-sm leading-relaxed text-ink-2">
|
|
<Inline value={block.text} />
|
|
</p>
|
|
));
|
|
TextBlock.displayName = 'TextBlock';
|
|
|
|
const HeadingBlock = React.memo(({ block }) => (
|
|
<div className="space-y-0.5">
|
|
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
|
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
|
</div>
|
|
));
|
|
HeadingBlock.displayName = 'HeadingBlock';
|
|
|
|
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
|
const KpisBlock = React.memo(({ block }) => (
|
|
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
|
{block.items.map((item) => (
|
|
<div
|
|
key={item.label}
|
|
className="rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs"
|
|
>
|
|
<p className="truncate text-[10px] uppercase tracking-wide text-ink-4">{item.label}</p>
|
|
<div className="mt-0.5 flex items-baseline gap-1.5">
|
|
<span className={cn(
|
|
'font-heading text-title font-bold tabular-nums',
|
|
item.tone ? TONE_TEXT[item.tone] : 'text-ink-1'
|
|
)}>
|
|
{item.value}
|
|
</span>
|
|
{typeof item.delta === 'number' && item.delta !== 0 && (
|
|
<span className={cn(
|
|
'inline-flex items-center text-[10px] font-semibold',
|
|
item.delta > 0 ? 'text-success' : 'text-destructive'
|
|
)}>
|
|
{item.delta > 0
|
|
? <ArrowUpRight className="h-2.5 w-2.5" aria-hidden="true" />
|
|
: <ArrowDownRight className="h-2.5 w-2.5" aria-hidden="true" />}
|
|
{Math.abs(item.delta)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
{item.sub && <p className="mt-0.5 truncate text-[10px] text-ink-4">{item.sub}</p>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
));
|
|
KpisBlock.displayName = 'KpisBlock';
|
|
|
|
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
|
const StatusBlock = React.memo(({ block }) => (
|
|
<ul className="space-y-1.5">
|
|
{block.items.map((item) => (
|
|
<li key={item.label} className="flex items-start gap-2">
|
|
<span
|
|
className={cn(
|
|
'mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full',
|
|
item.ok ? 'bg-success-muted text-success' : 'bg-warning-muted text-warning'
|
|
)}
|
|
>
|
|
{item.ok
|
|
? <Check className="h-2.5 w-2.5" aria-hidden="true" />
|
|
: <TriangleAlert className="h-2.5 w-2.5" aria-hidden="true" />}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-body-sm font-medium text-ink-1">{item.label}</span>
|
|
{item.value && (
|
|
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-2">
|
|
{item.value}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{item.note && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.note}</p>}
|
|
</div>
|
|
<span className="sr-only">{item.ok ? 'Passing' : 'Needs attention'}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
));
|
|
StatusBlock.displayName = 'StatusBlock';
|
|
|
|
const MetersBlock = React.memo(({ block }) => (
|
|
<div className="space-y-2.5">
|
|
{block.items.map((item) => (
|
|
<div key={item.label} className="space-y-1">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="truncate text-caption text-ink-2">{item.label}</span>
|
|
<span className="flex shrink-0 items-baseline gap-1.5">
|
|
{item.weight != null && <span className="text-[10px] text-ink-4">{item.weight}%</span>}
|
|
<span className="text-caption font-semibold tabular-nums text-ink-1">{item.value}</span>
|
|
</span>
|
|
</div>
|
|
<ProgressBar value={item.value} max={item.max ?? 100} tone={item.tone ?? 'score'} size="sm" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
));
|
|
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 <span className="text-ink-4">—</span>;
|
|
if (typeof value === 'object') {
|
|
if (value.badge) {
|
|
return <Badge variant={TONE_BADGE[value.tone] || 'neutral'} size="sm">{value.badge}</Badge>;
|
|
}
|
|
return (
|
|
<span className={cn('tabular-nums', value.tone ? TONE_TEXT[value.tone] : undefined)}>
|
|
{value.value}
|
|
</span>
|
|
);
|
|
}
|
|
return <span className="tabular-nums">{value}</span>;
|
|
};
|
|
|
|
return (
|
|
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
|
|
<table className="w-full border-collapse text-caption">
|
|
{block.caption && <caption className="sr-only">{block.caption}</caption>}
|
|
<thead>
|
|
<tr className="border-b border-border bg-surface-subtle">
|
|
{block.columns.map((column) => (
|
|
<th
|
|
key={column.key}
|
|
scope="col"
|
|
className={cn(
|
|
'whitespace-nowrap px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
|
column.align === 'right' ? 'text-right' : 'text-left'
|
|
)}
|
|
>
|
|
{column.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{block.rows.map((row, i) => (
|
|
<tr key={i} className="border-b border-border last:border-0">
|
|
{block.columns.map((column, ci) => (
|
|
<td
|
|
key={column.key}
|
|
className={cn(
|
|
'px-2.5 py-2 align-middle',
|
|
column.align === 'right' ? 'text-right' : 'text-left',
|
|
ci === 0 ? 'font-medium text-ink-1' : 'text-ink-2'
|
|
)}
|
|
>
|
|
{cell(row[column.key])}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
});
|
|
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 (
|
|
<div className="space-y-2">
|
|
{block.steps.map((step) => (
|
|
<div key={step.label}>
|
|
<div className="mb-1 flex items-baseline justify-between gap-2">
|
|
<span className="text-caption text-ink-2">{step.label}</span>
|
|
<span className="flex shrink-0 items-baseline gap-1.5">
|
|
{step.rate != null && <span className="text-[10px] text-ink-4">{step.rate}%</span>}
|
|
<span className="text-caption font-semibold tabular-nums text-ink-1">{step.count}</span>
|
|
</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-surface-sunken">
|
|
<div
|
|
className="h-full rounded-full bg-krow-blue transition-all duration-slow ease-out"
|
|
style={{ width: `${Math.max((step.count / max) * 100, step.count > 0 ? 4 : 0)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
});
|
|
FunnelBlock.displayName = 'FunnelBlock';
|
|
|
|
const ListBlock = React.memo(({ block }) => {
|
|
const Tag = block.ordered ? 'ol' : 'ul';
|
|
return (
|
|
<Tag className="space-y-1.5">
|
|
{block.items.map((item, i) => (
|
|
<li key={i} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
|
{block.ordered ? (
|
|
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
|
{i + 1}
|
|
</span>
|
|
) : (
|
|
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
|
|
)}
|
|
<span className="min-w-0"><Inline value={item} /></span>
|
|
</li>
|
|
))}
|
|
</Tag>
|
|
);
|
|
});
|
|
ListBlock.displayName = 'ListBlock';
|
|
|
|
/** Findings, each in a tinted card so severity is scannable. */
|
|
const InsightsBlock = React.memo(({ block }) => (
|
|
<div className="space-y-2">
|
|
{block.items.map((item, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn('rounded-xl border px-3 py-2.5', TONE_SURFACE[item.tone] || TONE_SURFACE.neutral)}
|
|
>
|
|
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
|
{item.body && (
|
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
));
|
|
InsightsBlock.displayName = 'InsightsBlock';
|
|
|
|
/** Recommended steps — numbered, because order is the recommendation. */
|
|
const ActionsBlock = React.memo(({ block }) => (
|
|
<ol className="space-y-2">
|
|
{block.items.map((item, i) => (
|
|
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
|
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-[10px] font-bold text-white">
|
|
{i + 1}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
|
{item.body && (
|
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
));
|
|
ActionsBlock.displayName = 'ActionsBlock';
|
|
|
|
const BadgesBlock = React.memo(({ block }) => (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{block.items.map((item) => (
|
|
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
|
{item.label}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
));
|
|
BadgesBlock.displayName = 'BadgesBlock';
|
|
|
|
const TimelineBlock = React.memo(({ block }) => (
|
|
<ol className="relative space-y-3">
|
|
{block.items.map((item, i) => (
|
|
<li key={i} className="flex gap-3">
|
|
<div className="flex flex-col items-center">
|
|
<span
|
|
className={cn(
|
|
'mt-1 h-2 w-2 shrink-0 rounded-full',
|
|
item.tone === 'success' ? 'bg-success'
|
|
: item.tone === 'warning' ? 'bg-warning'
|
|
: item.current ? 'bg-krow-blue' : 'bg-ink-4'
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
{i < block.items.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
|
|
</div>
|
|
<div className="min-w-0 flex-1 pb-1">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-body-sm font-medium text-ink-1">{item.title}</span>
|
|
{item.timestamp && (
|
|
<span className="shrink-0 text-[10px] text-ink-4">{item.timestamp}</span>
|
|
)}
|
|
</div>
|
|
{item.description && (
|
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.description}</p>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
));
|
|
TimelineBlock.displayName = 'TimelineBlock';
|
|
|
|
const NoteBlock = React.memo(({ block }) => (
|
|
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
|
<Inline value={block.text} />
|
|
</p>
|
|
));
|
|
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 }) => (
|
|
<div className="space-y-3">
|
|
{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 (
|
|
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
|
|
<Renderer block={block} />
|
|
{/* The cursor trails the final block only while text is still arriving. */}
|
|
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
|
|
<span
|
|
className="ml-0.5 inline-block h-3.5 w-1.5 -mb-0.5 animate-caret bg-krow-blue align-middle"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
));
|
|
ResponseDocument.displayName = 'ResponseDocument';
|
|
|
|
export { toneForScore };
|