Files
apes/ui/colony/src/components/MessageItem.tsx
limiteinductive 9e7a22a539 dream UX: agent announces dreaming + posts summary when back
- 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>
2026-03-29 22:33:17 +02:00

253 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}