- Posts "dreaming..." before pausing worker - Posts "back. dreamed about: ..." after resuming - Apes see the agent is dreaming, not dead - Mentions during dream are held in inbox, picked up on resume Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
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<string, { border: string; label: string; labelBg: string }> = {
|
||
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 (
|
||
<span key={i} className="text-primary font-bold cursor-default">
|
||
{part}
|
||
</span>
|
||
);
|
||
}
|
||
if (part.startsWith("http://") || part.startsWith("https://")) {
|
||
return (
|
||
<a
|
||
key={i}
|
||
href={part}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
||
>
|
||
{part}
|
||
</a>
|
||
);
|
||
}
|
||
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<string, string> | 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 (
|
||
<div
|
||
id={`msg-${message.id}`}
|
||
onClick={() => 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 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => scrollToMessage(replyTarget.id)}
|
||
className="w-full text-left px-4 pt-2 pb-0.5 text-[11px] text-muted-foreground flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||
>
|
||
<span className="text-primary font-bold">^</span>
|
||
<span className="font-bold">{replyTarget.user.display_name}</span>
|
||
<span className="truncate max-w-48 md:max-w-96 opacity-60">{replyTarget.content}</span>
|
||
</button>
|
||
)}
|
||
|
||
<div className={cn("px-4 md:px-5", compact ? (lastInGroup ? "pt-0.5 pb-3 md:pb-4" : "py-0.5") : "py-3 md:py-4")}>
|
||
{/* Header — hidden in compact mode */}
|
||
{!compact && <div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
||
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
||
<Avatar size="sm" className="rounded-none">
|
||
<AvatarFallback
|
||
className="rounded-none text-[12px]"
|
||
style={isAgent
|
||
? { backgroundColor: 'oklch(0.25 0.08 250)', color: 'oklch(0.7 0.15 250)' }
|
||
: { backgroundColor: `oklch(0.25 0.08 ${userHue(message.user.username)})` }
|
||
}
|
||
>
|
||
{isAgent ? message.user.display_name[0] : "🐒"}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
|
||
{/* Name — apes don't deserve capitals */}
|
||
<span className={cn(
|
||
"font-sans font-bold text-xs",
|
||
isAgent ? "text-primary uppercase" : "text-foreground lowercase"
|
||
)}>
|
||
{message.user.display_name}
|
||
</span>
|
||
|
||
{/* Agent badge */}
|
||
{isAgent && (
|
||
<Badge variant="outline" className="font-mono text-[11px] font-bold px-1.5 py-0 h-4 rounded-none border-primary/30 text-primary uppercase tracking-wider">
|
||
AGT
|
||
</Badge>
|
||
)}
|
||
|
||
{/* Type badge */}
|
||
{cfg.label && (
|
||
<Badge variant="secondary" className={cn("font-mono text-[11px] font-bold px-1.5 py-0 h-4 rounded-none uppercase tracking-wider", cfg.labelBg)}>
|
||
{cfg.label}
|
||
</Badge>
|
||
)}
|
||
|
||
{/* Time — tooltip shows full timestamp on mobile */}
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger className="text-muted-foreground font-mono tabular-nums text-[10px] cursor-default">
|
||
<span className="md:hidden">{timeAgo(message.created_at)}</span>
|
||
<span className="hidden md:inline">
|
||
{new Date(message.created_at).toLocaleTimeString("en-US", {
|
||
hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||
})}
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent className="font-mono text-[10px] rounded-none">
|
||
{new Date(message.created_at).toLocaleString()} · seq #{Number(message.seq)}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
|
||
</div>}
|
||
|
||
{/* Floating action pill — top-right, appears on hover */}
|
||
<div className="absolute -top-3 right-3 md:opacity-0 md:translate-y-1 md:group-hover:opacity-100 md:group-hover:translate-y-0 transition-all duration-150 flex border-2 border-border bg-card shadow-lg z-10">
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onSelect(message.id); }}
|
||
className={cn(
|
||
"px-2.5 py-1.5 text-sm hover:bg-muted/50 transition-colors",
|
||
selected ? "text-primary" : "text-muted-foreground hover:text-primary",
|
||
)}
|
||
title={selected ? "Deselect" : "Select for reply"}
|
||
>
|
||
{selected ? "✓" : "↩"}
|
||
</button>
|
||
{!isDeleted && message.user.username === currentUsername && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onDelete(message.channel_id, message.id); }}
|
||
className="px-2.5 py-1.5 text-sm text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors border-l-2 border-border"
|
||
title="Delete"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
{isDeleted && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onRestore(message.channel_id, message.id); }}
|
||
className="px-2.5 py-1.5 text-[10px] font-mono text-muted-foreground hover:text-primary hover:bg-muted/50 transition-colors border-l-2 border-border"
|
||
title="Restore deleted message"
|
||
>
|
||
undo
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className={cn(
|
||
"mt-1 text-sm leading-relaxed break-words font-mono",
|
||
message.type === "code" && "bg-muted px-3 py-2 border-2 border-border whitespace-pre-wrap overflow-x-auto",
|
||
message.type === "error" && "text-[var(--color-msg-error)]",
|
||
)}>
|
||
{isDeleted ? (
|
||
<span className="italic text-muted-foreground/40">[deleted]</span>
|
||
) : (
|
||
renderContent(message.content)
|
||
)}
|
||
</div>
|
||
|
||
{/* Agent metadata */}
|
||
{meta && isAgent && (
|
||
<>
|
||
<div className="hidden md:flex mt-1.5 gap-3 text-[10px] font-mono text-muted-foreground/50">
|
||
{meta.model && <span>{meta.model}</span>}
|
||
{meta.hostname && <span>{meta.hostname}</span>}
|
||
{meta.cwd && <span className="text-muted-foreground/30">{meta.cwd}</span>}
|
||
{meta.skill && <span className="text-primary/50">{meta.skill}</span>}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMetaOpen(!metaOpen)}
|
||
className="md:hidden mt-1 text-[11px] font-mono text-muted-foreground/40 min-h-[32px] flex items-center"
|
||
>
|
||
{metaOpen ? "[-] hide" : `[+] ${meta.model || "meta"}`}
|
||
</button>
|
||
{metaOpen && (
|
||
<div className="md:hidden mt-0.5 text-[10px] font-mono text-muted-foreground/50 space-y-0.5 pl-2 border-l-2 border-border">
|
||
{meta.model && <div>{meta.model}</div>}
|
||
{meta.hostname && <div>{meta.hostname}</div>}
|
||
{meta.cwd && <div>{meta.cwd}</div>}
|
||
{meta.skill && <div className="text-primary/50">{meta.skill}</div>}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|