import { useState } from "react"; import type { Message } from "@/types/Message"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; interface Props { message: Message; compact?: boolean; lastInGroup?: boolean; replyTarget?: Message; onSelect: (id: string) => void; onDelete: (channelId: string, msgId: string) => void; onRestore: (channelId: string, msgId: string) => void; currentUsername: string; selected: boolean; } const TYPE_CONFIG: Record = { text: { border: "border-l-transparent", label: "", labelBg: "" }, code: { border: "border-l-[var(--color-msg-code)]", label: "CODE", labelBg: "bg-[var(--color-msg-code)]/15 text-[var(--color-msg-code)]" }, result: { border: "border-l-[var(--color-msg-result)]", label: "RESULT", labelBg: "bg-[var(--color-msg-result)]/15 text-[var(--color-msg-result)]" }, error: { border: "border-l-[var(--color-msg-error)]", label: "ERROR", labelBg: "bg-[var(--color-msg-error)]/15 text-[var(--color-msg-error)]" }, plan: { border: "border-l-[var(--color-msg-plan)]", label: "PLAN", labelBg: "bg-[var(--color-msg-plan)]/15 text-[var(--color-msg-plan)]" }, }; function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return "now"; if (mins < 60) return `${mins}m`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h`; return `${Math.floor(hrs / 24)}d`; } function renderContent(text: string) { // Split on @mentions and URLs // URL regex: stop before trailing punctuation like ),. etc const parts = text.split(/((?:https?:\/\/)[^\s),.\]}>]+|@[\w-]+)/g); return parts.map((part, i) => { if (part.startsWith("@")) { return ( {part} ); } if (part.startsWith("http://") || part.startsWith("https://")) { return ( e.stopPropagation()} className="text-primary underline underline-offset-2 hover:text-primary/80" > {part} ); } return part; }); } // Stable OKLCH hue from username โ€” same user always gets same color function userHue(username: string): number { let hash = 0; for (let i = 0; i < username.length; i++) { hash = username.charCodeAt(i) + ((hash << 5) - hash); } return Math.abs(hash) % 360; } export function MessageItem({ message, compact, lastInGroup, replyTarget, onSelect, onDelete, onRestore, currentUsername, selected }: Props) { const [metaOpen, setMetaOpen] = useState(false); const isAgent = message.user.role === "agent"; const isDeleted = !!message.deleted_at; const cfg = TYPE_CONFIG[message.type] || TYPE_CONFIG.text; const meta = message.metadata as Record | null; function scrollToMessage(id: string) { const el = document.getElementById(`msg-${id}`); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); el.classList.add("!bg-primary/10"); setTimeout(() => el.classList.remove("!bg-primary/10"), 1500); } } return (
onSelect(message.id)} className={cn( "group relative border-l-4 transition-all duration-300 cursor-pointer", lastInGroup ? "border-b border-border/50" : "", cfg.border, selected ? "!border-l-primary bg-primary/5" : isAgent ? "bg-card" : "bg-background", "hover:bg-muted/30", )} > {/* Reply context โ€” click to scroll to quoted message */} {replyTarget && ( )}
{/* Header โ€” hidden in compact mode */} {!compact &&
{/* Avatar โ€” ape emoji with OKLCH color, agents get first letter */} {isAgent ? message.user.display_name[0] : "๐Ÿ’"} {/* Name โ€” apes don't deserve capitals */} {message.user.display_name} {/* Agent badge */} {isAgent && ( AGT )} {/* Type badge */} {cfg.label && ( {cfg.label} )} {/* Time โ€” tooltip shows full timestamp on mobile */} {timeAgo(message.created_at)} {new Date(message.created_at).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", })} {new Date(message.created_at).toLocaleString()} ยท seq #{Number(message.seq)}
} {/* Floating action pill โ€” top-right, appears on hover */}
{!isDeleted && message.user.username === currentUsername && ( )} {isDeleted && ( )}
{/* Content */}
{isDeleted ? ( [deleted] ) : ( renderContent(message.content) )}
{/* Agent metadata */} {meta && isAgent && ( <>
{meta.model && {meta.model}} {meta.hostname && {meta.hostname}} {meta.cwd && {meta.cwd}} {meta.skill && {meta.skill}}
{metaOpen && (
{meta.model &&
{meta.model}
} {meta.hostname &&
{meta.hostname}
} {meta.cwd &&
{meta.cwd}
} {meta.skill &&
{meta.skill}
}
)} )}
); }