// Chat Message Row // Purpose: Defines the Chat Message Row module and the local helpers/components used in this file. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. import { useState } from 'react'; import { FaDiscord } from 'react-icons/fa'; import Linkify from 'linkify-react'; import { useSessionSelector } from '../../context/SessionContext.jsx'; import RoverLabel from '../RoverLabel/index.jsx'; import ChatEmbeds from './ChatEmbeds.jsx'; const LINK_OPTIONS = { target: '_blank', rel: 'noopener noreferrer', className: 'text-sky-300 underline break-all', }; function roleColors(role) { switch (role) { case 'admin': case 'lockdown': return 'text-amber-300'; case 'spectator': return 'text-slate-400'; default: return 'text-sky-300'; } } function isBotMessage(message) { return Boolean(message?.bot); } function formatTime(ts) { const date = new Date(ts); return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; } function displayName(message) { return message.nickname || message.socketId?.slice(0, 6) || 'unknown'; } // The chat rows use py-0.5 for their compact vertical rhythm, but the desired avatar // treatment is edge-to-edge inside that same row height. The negative vertical margin // lets the image cover the row padding visually without increasing the row's measured // height, while the matching calc() size keeps the avatar square after adding that // top/bottom coverage. const CHAT_ROW_FULL_HEIGHT_AVATAR_CLASS = '-my-0.5 flex h-5 w-5 shrink-0 overflow-hidden rounded-none'; function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) { if (!guildIconUrl && !userAvatarUrl) return null; return ( ); } function ProfileAvatar({ imageUrl, label }) { if (!imageUrl) return null; return ( ); } export function ChatIdentity({ message, toolsToggle = null }) { const discordLabel = message.fromDiscord ? `${message.discordGuildName || 'Discord'} · ${displayName(message)}` : null; const isBot = isBotMessage(message); const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role); return ( {message.fromDiscord ? ( ) : null} {displayName(message)} {!message.profileImage ? ( ) : null} {isBot ? ( bot ) : null} {message.roverId && ( )} {toolsToggle} ); } function chatRowClass(message, variant = 'message') { // Typing indicators intentionally reuse the normal chat row shell so avatar, // nickname, rover identity, and Discord identity all stay visually aligned // with real chat messages. The darker slate treatment separates the temporary // "typing..." state without requiring a second component with duplicated // identity markup. if (variant === 'typing') { return 'flex flex-col gap-0.5 rounded-md border border-slate-600/40 bg-slate-900/40 px-0.5 py-0.5 text-sm text-neutral-100 italic opacity-80'; } if (isBotMessage(message)) { return 'flex flex-col gap-0.5 rounded-md border border-emerald-500/40 bg-emerald-950 px-0.5 py-0.5 text-sm text-neutral-100'; } const isAdmin = message.role === 'admin' || message.role === 'lockdown'; return `flex flex-col gap-0.5 rounded-md bg-neutral-800 px-0.5 py-0.5 text-sm text-neutral-100 ${ isAdmin ? 'border border-amber-400/30' : message.fromDiscord ? 'border border-indigo-400/30 bg-indigo-950' : '' }`; } export default function ChatMessageRow({ message, variant = 'message' }) { const isBot = isBotMessage(message); const isTyping = variant === 'typing'; const role = useSessionSelector((state) => state.session?.role || null); const isSpectator = role === 'spectator'; const [open, setOpen] = useState(false); // Typing payloads are transient presence events, not persisted chat messages. // Treating them as a row variant prevents tool expansion and message-specific // controls from appearing if a future typing payload happens to carry fields // that overlap with stored chat message data. const toolCalls = !isTyping && Array.isArray(message?.toolCalls) ? message.toolCalls : []; const hasToolCalls = toolCalls.length > 0; // Spectator chat should expose tool details without requiring interaction, but // only rows that actually contain tools need the expanded two-section layout. // Without this guard, ordinary spectator chat messages render the sender and // message body on separate lines simply because the spectator role forces the // row open even when there is no dropdown content to show. const isOpen = hasToolCalls && (isSpectator || open); const hasText = Boolean(String(message?.text || '').trim()); const rowText = isTyping ? 'typing...' : message.text; const toolsToggle = hasToolCalls ? ( ) : null; return (
{!isOpen && (hasText || isTyping) ? ( {` ${rowText}`} ) : null} {!isTyping ? ( {formatTime(message.ts)} ) : null}
{isOpen && hasToolCalls ? (
{toolCalls.map((entry, idx) => { const status = String(entry?.status || 'unknown'); const statusLabel = status === 'ok' ? 'ok' : status === 'blocked' ? 'blocked' : status === 'error' ? 'error' : 'unknown'; const argsText = JSON.stringify(entry?.args && typeof entry.args === 'object' ? entry.args : {}); return (
{statusLabel} {entry?.tool || 'unknown'} args={argsText}
{entry?.error ?
error: {String(entry.error)}
: null}
); })}
) : null} {isOpen && hasText ? (
{message.text}
) : null} {!isTyping && hasText ? : null}
); }