128 lines
4.5 KiB
JavaScript
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 };
|