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

@@ -1,132 +1,47 @@
import * as React from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
/**
* Renders the light markup the capabilities emit — `**bold**`, bullets,
* numbered items, `_asides_`, and ✓/⚠ check lines.
* The thinking state, shown between sending and the first block arriving.
*
* A full markdown renderer is available in the project, but this content is
* produced locally in a known shape, and parsing five constructs keeps the
* assistant's typography inside the design system rather than inheriting a
* library's defaults.
* Labelled as well as animated: three moving dots mean nothing to a screen
* reader, and the visually-hidden text is what tells one that a reply is coming.
*/
const BOLD = /\*\*(.+?)\*\*/g;
function inline(line, keyPrefix) {
const parts = [];
let last = 0;
let match;
BOLD.lastIndex = 0;
while ((match = BOLD.exec(line)) !== null) {
if (match.index > last) parts.push(line.slice(last, match.index));
parts.push(
<strong key={`${keyPrefix}-${match.index}`} className="font-semibold text-ink-1">
{match[1]}
</strong>
);
last = match.index + match[0].length;
}
if (last < line.length) parts.push(line.slice(last));
return parts.length ? parts : line;
}
export function RichText({ text, className }) {
const lines = React.useMemo(() => String(text).split('\n'), [text]);
return (
<div className={cn('space-y-1.5 text-body-sm text-ink-2', className)}>
{lines.map((line, i) => {
if (!line.trim()) return <div key={i} className="h-1.5" aria-hidden="true" />;
/* Aside — a caveat the capabilities wrap in underscores. */
if (line.startsWith('_') && line.endsWith('_')) {
return (
<p key={i} className="border-l-2 border-border pl-2.5 text-caption italic text-ink-3 leading-relaxed">
{inline(line.slice(1, -1), i)}
</p>
);
}
/* Bullet. */
if (line.startsWith('• ')) {
return (
<div key={i} className="flex gap-2 leading-relaxed">
<span className="text-krow-blue shrink-0" aria-hidden="true">•</span>
<span className="min-w-0">{inline(line.slice(2), i)}</span>
</div>
);
}
/* Indented continuation of a numbered item. */
if (line.startsWith(' ')) {
return (
<p key={i} className="pl-6 text-caption text-ink-3 leading-relaxed">
{inline(line.trim(), i)}
</p>
);
}
/* Numbered item. */
const numbered = line.match(/^(\d+)\.\s+(.*)$/);
if (numbered) {
return (
<div key={i} className="flex gap-2.5 leading-relaxed">
<span className="grid place-items-center w-4 h-4 mt-0.5 shrink-0 rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{numbered[1]}
</span>
<span className="min-w-0">{inline(numbered[2], i)}</span>
</div>
);
}
/* Health-check line. */
if (line.startsWith('✓ ') || line.startsWith('⚠ ')) {
const ok = line.startsWith('✓');
return (
<div key={i} className="flex gap-2 leading-relaxed">
<span className={cn('shrink-0 font-semibold', ok ? 'text-success' : 'text-warning')} aria-hidden="true">
{ok ? '✓' : '⚠'}
</span>
<span className="min-w-0">{inline(line.slice(2), i)}</span>
</div>
);
}
return <p key={i} className="leading-relaxed">{inline(line, i)}</p>;
})}
</div>
);
}
/** The three-dot indicator shown before the first delta arrives. */
export function ThinkingIndicator() {
return (
<div className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-3 py-2.5 shadow-xs">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 rounded-full bg-ink-4 animate-typing"
style={{ animationDelay: `${i * 0.18}s` }}
/>
))}
<span className="sr-only">Assistant is thinking</span>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-krow-blue-tint text-krow-blue">
<Sparkles className="h-3 w-3" aria-hidden="true" />
</span>
<span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1.5 w-1.5 animate-typing rounded-full bg-ink-4"
style={{ animationDelay: `${i * 0.18}s` }}
/>
))}
</span>
<span className="sr-only">Krow AI is preparing a response</span>
</div>
);
}
/**
* One turn in the conversation. The user's own words sit in a filled bubble; the
* assistant's answer runs full width, because these answers are structured
* multi-paragraph reports and a chat bubble would waste a third of a 380px
* column on padding.
* One turn.
*
* The user's words sit in a filled bubble; the assistant's reply runs the full
* width of the panel. That asymmetry is deliberate — these replies are reports
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them.
*/
export function Message({ role, text, streaming, stopped }) {
export const Message = React.memo(({ role, text, blocks, streaming, stopped }) => {
if (role === 'user') {
return (
<div className="flex justify-end animate-slide-up">
<div className="max-w-[85%] rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
<div className="flex justify-end">
<div className="max-w-[85%] animate-slide-up rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
{text}
</div>
</div>
@@ -135,16 +50,24 @@ export function Message({ role, text, streaming, stopped }) {
return (
<div className="animate-slide-up">
<RichText text={text} />
{streaming && (
<span
className="inline-block w-1.5 h-3.5 ml-0.5 -mb-0.5 bg-krow-blue animate-caret"
aria-hidden="true"
/>
)}
{stopped && (
<p className="mt-1.5 text-caption italic text-ink-4">Stopped early.</p>
)}
<div className="mb-2 flex items-center gap-1.5">
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-krow-blue-tint text-krow-blue">
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
</span>
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Krow AI</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
</div>
);
});
Message.displayName = 'Message';
/** A subtle divider between turns, so a long thread stays readable. */
export function TurnDivider() {
return <div className="h-px bg-border/60" role="presentation" />;
}
export { cn };