replicalab / frontend /src /components /NegotiationLog.tsx
maxxie114's picture
Initial HF Spaces deployment
80d8c84
import { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { MessageSquare, Play, Pencil } from 'lucide-react';
import type { NegotiationMessage } from '@/types';
import { cn, roleBgColor, roleLabel } from '@/lib/utils';
import AnimatedCharacter from '@/components/AnimatedCharacter';
import TypingText from '@/components/TypingText';
interface NegotiationLogProps {
messages: NegotiationMessage[];
episodeActive?: boolean;
disabled?: boolean;
onKickoff?: () => void;
onOpenEditor?: () => void;
className?: string;
}
export default function NegotiationLog({
messages,
episodeActive = false,
disabled = false,
onKickoff,
onOpenEditor,
className,
}: NegotiationLogProps) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
return (
<div className={cn('flex flex-col', className)}>
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<MessageSquare className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold">Negotiation Log</h2>
<span className="ml-auto rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{messages.length} messages
</span>
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{messages.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-6">
<AnimatedCharacter role="scientist" emotion="idle" size="lg" showEmoji={false} showAura={false} />
<span className="text-xl font-bold text-muted-foreground/30">VS</span>
<AnimatedCharacter role="lab_manager" emotion="idle" size="lg" showEmoji={false} showAura={false} />
</div>
<p>
{episodeActive
? 'Episode ready. Start the first round to see the negotiation.'
: 'Start an episode to see them negotiate.'}
</p>
{episodeActive && (
<div className="flex flex-wrap items-center justify-center gap-2">
<button
onClick={onKickoff}
disabled={disabled || !onKickoff}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Play className="h-3.5 w-3.5" />
Advance First Round
</button>
<button
onClick={onOpenEditor}
disabled={disabled || !onOpenEditor}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50"
>
<Pencil className="h-3.5 w-3.5" />
Open Protocol Editor
</button>
</div>
)}
</div>
) : (
messages.map((msg, i) => (
<MessageBubble
key={`${i}-${msg.round}-${msg.role}`}
message={msg}
index={i}
isLatest={i === messages.length - 1}
/>
))
)}
<div ref={endRef} />
</div>
</div>
);
}
function MessageBubble({
message,
index,
isLatest,
}: {
message: NegotiationMessage;
index: number;
isLatest: boolean;
}) {
const isScientist = message.role === 'scientist';
const actionType = message.action_type ?? undefined;
return (
<motion.div
className={cn('flex gap-2.5', isScientist ? 'flex-row' : 'flex-row-reverse')}
initial={{ opacity: 0, x: isScientist ? -20 : 20, y: 10 }}
animate={{ opacity: 1, x: 0, y: 0 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 25,
delay: index * 0.1,
}}
>
{/* Animated avatar */}
<div className="mt-4 shrink-0">
<AnimatedCharacter
role={message.role}
action={isLatest ? actionType : undefined}
isSpeaking={isLatest}
isActive={isLatest}
size="md"
showEmoji={isLatest}
showAura={false}
showName={false}
/>
</div>
<div className={cn('flex max-w-[78%] flex-col gap-1', isScientist ? 'items-start' : 'items-end')}>
<div className="flex items-center gap-1.5 text-xs">
<span className={cn('font-semibold', isScientist ? 'text-scientist' : 'text-lab-manager')}>
{roleLabel(message.role)}
</span>
<span className="text-muted-foreground">· Round {message.round}</span>
</div>
<motion.div
className={cn(
'rounded-2xl border px-3.5 py-2.5 text-sm leading-relaxed',
roleBgColor(message.role),
isScientist ? 'rounded-tl-sm' : 'rounded-tr-sm',
)}
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: index * 0.1 + 0.1 }}
>
{isLatest ? (
<TypingText text={message.message} speed={15} />
) : (
message.message
)}
</motion.div>
{actionType && (
<motion.span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium',
isScientist
? 'border-scientist/20 bg-scientist/5 text-scientist'
: 'border-lab-manager/20 bg-lab-manager/5 text-lab-manager',
)}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 + 0.2 }}
>
{actionType.replace(/_/g, ' ')}
</motion.span>
)}
</div>
</motion.div>
);
}