update chatbox

This commit is contained in:
2026-08-08 00:39:16 +05:30
parent 90f14a73b2
commit 0cae250049
15 changed files with 1952 additions and 913 deletions

View File

@@ -0,0 +1,409 @@
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 };