From f88a4ef6a7eeb77e40f7c8eb7bc24c11f6dec267 Mon Sep 17 00:00:00 2001 From: limiteinductive Date: Sun, 29 Mar 2026 21:40:37 +0200 Subject: [PATCH] message grouping + date separators + linkified URLs - Consecutive same-sender messages within 5min collapse (compact mode) - Compact: no avatar/name/badges, aligned to content area, minimal padding - Date separators with horizontal lines between days - URLs auto-linkified in orange with underline - Links open in new tab, stopPropagation to not trigger select Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/colony/src/App.tsx | 73 +++++++++++++++--------- ui/colony/src/components/MessageItem.tsx | 32 ++++++++--- 2 files changed, 71 insertions(+), 34 deletions(-) diff --git a/ui/colony/src/App.tsx b/ui/colony/src/App.tsx index 44767c9..77779f4 100644 --- a/ui/colony/src/App.tsx +++ b/ui/colony/src/App.tsx @@ -206,32 +206,53 @@ export default function App() { no messages yet — start typing below ) : ( - messages.map((msg) => ( - s.id === msg.id)} - onSelect={(id) => { - setSelectedMessages((prev) => { - const exists = prev.find((s) => s.id === id); - if (exists) return prev.filter((s) => s.id !== id); - const target = messagesById.get(id); - if (!target) return prev; - return [...prev, { id, username: target.user.display_name, content: target.content }]; - }); - }} - onDelete={async (chId, msgId) => { - try { - await deleteMessage(chId, msgId); - loadMessages(); - } catch { - // ignore - } - }} - /> - )) + messages.map((msg, i) => { + const prev = i > 0 ? messages[i - 1] : null; + const sameSender = prev && prev.user.username === msg.user.username; + const withinWindow = prev && (new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime()) < 5 * 60 * 1000; + const compact = !!(sameSender && withinWindow && !msg.reply_to); + const prevDate = prev ? new Date(prev.created_at).toDateString() : null; + const thisDate = new Date(msg.created_at).toDateString(); + const showDate = prevDate !== thisDate; + + return ( +
+ {showDate && ( +
+
+ + {new Date(msg.created_at).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })} + +
+
+ )} + s.id === msg.id)} + onSelect={(id) => { + setSelectedMessages((prevSel) => { + const exists = prevSel.find((s) => s.id === id); + if (exists) return prevSel.filter((s) => s.id !== id); + const target = messagesById.get(id); + if (!target) return prevSel; + return [...prevSel, { id, username: target.user.display_name, content: target.content }]; + }); + }} + onDelete={async (chId, msgId) => { + try { + await deleteMessage(chId, msgId); + loadMessages(); + } catch { + // ignore + } + }} + /> +
+ ); + }) )} diff --git a/ui/colony/src/components/MessageItem.tsx b/ui/colony/src/components/MessageItem.tsx index 83d4dc9..89539d5 100644 --- a/ui/colony/src/components/MessageItem.tsx +++ b/ui/colony/src/components/MessageItem.tsx @@ -7,6 +7,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp interface Props { message: Message; + compact?: boolean; replyTarget?: Message; onSelect: (id: string) => void; onDelete: (channelId: string, msgId: string) => void; @@ -33,8 +34,8 @@ function timeAgo(dateStr: string): string { } function renderContent(text: string) { - // Split on @mentions and render them as highlighted spans - const parts = text.split(/(@[\w-]+)/g); + // Split on @mentions and URLs + const parts = text.split(/((?:https?:\/\/)[^\s]+|@[\w-]+)/g); return parts.map((part, i) => { if (part.startsWith("@")) { return ( @@ -43,6 +44,20 @@ function renderContent(text: string) { ); } + if (part.startsWith("http://") || part.startsWith("https://")) { + return ( + e.stopPropagation()} + className="text-primary underline underline-offset-2 hover:text-primary/80" + > + {part} + + ); + } return part; }); } @@ -56,7 +71,7 @@ function userHue(username: string): number { return Math.abs(hash) % 360; } -export function MessageItem({ message, replyTarget, onSelect, onDelete, currentUsername, selected }: Props) { +export function MessageItem({ message, compact, replyTarget, onSelect, onDelete, currentUsername, selected }: Props) { const [metaOpen, setMetaOpen] = useState(false); const isAgent = message.user.role === "agent"; const isDeleted = !!message.deleted_at; @@ -77,7 +92,8 @@ export function MessageItem({ message, replyTarget, onSelect, onDelete, currentU id={`msg-${message.id}`} onClick={() => onSelect(message.id)} className={cn( - "group relative border-b border-border/50 border-l-4 transition-all duration-300 cursor-pointer", + "group relative border-l-4 transition-all duration-300 cursor-pointer", + compact ? "" : "border-b border-border/50", cfg.border, selected ? "!border-l-primary bg-primary/5" : isAgent ? "bg-card" : "bg-background", "hover:bg-muted/30", @@ -96,9 +112,9 @@ export function MessageItem({ message, replyTarget, onSelect, onDelete, currentU )} -
- {/* Header */} -
+
+ {/* Header — hidden in compact mode */} + {!compact &&
{/* Avatar — ape emoji with OKLCH color, agents get first letter */} -
+
} {/* Floating action pill — top-right, appears on hover */}