Files
doormilxpress_astryx/src/components/ai-assistant/AssistantMessage.jsx
2026-08-20 18:18:10 +05:30

128 lines
4.5 KiB
JavaScript

import * as React from 'react';
import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
/**
* The thinking state, shown between sending and the first block arriving.
*
* 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.
*/
export function ThinkingIndicator() {
return (
<div className="flex items-center gap-2">
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
<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">Owliver is preparing a response</span>
</div>
);
}
/**
* 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.
*/
/**
* How this conversation was rated.
*
* Offered on the latest answer only, because a rating belongs to the
* conversation rather than to a paragraph of it — thumbs on every turn would
* ask a reader to score an answer out of the exchange that produced it, and
* each one would set the same value anyway.
*
* Hidden until the turn is hovered or focused, the way History's delete control
* already is: a thread of reports should not carry two permanent buttons under
* every answer.
*/
function FeedbackControls({ feedback, onFeedback }) {
const rate = (rating) => onFeedback(feedback?.rating === rating ? null : rating);
return (
<div
className={cn(
'mt-2 flex items-center gap-0.5 transition-opacity',
/* Stays visible once rated, so a reader can see and change their own
answer rather than having to hunt for it again. */
feedback ? 'opacity-100' : 'opacity-0 group-hover/turn:opacity-100 group-focus-within/turn:opacity-100'
)}
>
{[
{ rating: 'up', Icon: ThumbsUp, label: 'This answer was useful' },
{ rating: 'down', Icon: ThumbsDown, label: 'This answer was not useful' },
].map(({ rating, Icon, label }) => (
<button
key={rating}
type="button"
onClick={() => rate(rating)}
aria-label={label}
aria-pressed={feedback?.rating === rating}
className={cn(
`rounded p-1 transition-colors focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
feedback?.rating === rating
? 'text-krow-blue'
: 'text-ink-4 hover:text-ink-2'
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</button>
))}
</div>
);
}
export const Message = React.memo(
/** @param {any} props */
({ role, text, blocks, streaming, stopped, onPrompt, feedback = null, onFeedback = null }) => {
if (role === 'user') {
return (
<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>
);
}
return (
<div className="group/turn animate-slide-up">
<div className="mb-2 flex items-center gap-1.5">
<OwliverAvatar className="h-5 w-5" rounded="rounded-full" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
{/* Only where a handler was passed — a turn nobody is collecting feedback
for renders exactly as it did before this existed. */}
{onFeedback && !streaming && (
<FeedbackControls feedback={feedback} onFeedback={onFeedback} />
)}
</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 };