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) <noreply@anthropic.com>
This commit is contained in:
@@ -206,20 +206,39 @@ export default function App() {
|
|||||||
no messages yet — start typing below
|
no messages yet — start typing below
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((msg) => (
|
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 (
|
||||||
|
<div key={msg.id}>
|
||||||
|
{showDate && (
|
||||||
|
<div className="flex items-center gap-3 px-5 py-3">
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-widest">
|
||||||
|
{new Date(msg.created_at).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<MessageItem
|
<MessageItem
|
||||||
key={msg.id}
|
|
||||||
message={msg}
|
message={msg}
|
||||||
|
compact={compact}
|
||||||
replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined}
|
replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined}
|
||||||
currentUsername={getCurrentUsername()}
|
currentUsername={getCurrentUsername()}
|
||||||
selected={selectedMessages.some((s) => s.id === msg.id)}
|
selected={selectedMessages.some((s) => s.id === msg.id)}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
setSelectedMessages((prev) => {
|
setSelectedMessages((prevSel) => {
|
||||||
const exists = prev.find((s) => s.id === id);
|
const exists = prevSel.find((s) => s.id === id);
|
||||||
if (exists) return prev.filter((s) => s.id !== id);
|
if (exists) return prevSel.filter((s) => s.id !== id);
|
||||||
const target = messagesById.get(id);
|
const target = messagesById.get(id);
|
||||||
if (!target) return prev;
|
if (!target) return prevSel;
|
||||||
return [...prev, { id, username: target.user.display_name, content: target.content }];
|
return [...prevSel, { id, username: target.user.display_name, content: target.content }];
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onDelete={async (chId, msgId) => {
|
onDelete={async (chId, msgId) => {
|
||||||
@@ -231,7 +250,9 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message;
|
message: Message;
|
||||||
|
compact?: boolean;
|
||||||
replyTarget?: Message;
|
replyTarget?: Message;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onDelete: (channelId: string, msgId: string) => void;
|
onDelete: (channelId: string, msgId: string) => void;
|
||||||
@@ -33,8 +34,8 @@ function timeAgo(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(text: string) {
|
function renderContent(text: string) {
|
||||||
// Split on @mentions and render them as highlighted spans
|
// Split on @mentions and URLs
|
||||||
const parts = text.split(/(@[\w-]+)/g);
|
const parts = text.split(/((?:https?:\/\/)[^\s]+|@[\w-]+)/g);
|
||||||
return parts.map((part, i) => {
|
return parts.map((part, i) => {
|
||||||
if (part.startsWith("@")) {
|
if (part.startsWith("@")) {
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +44,20 @@ function renderContent(text: string) {
|
|||||||
</span>
|
</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;
|
return part;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -56,7 +71,7 @@ function userHue(username: string): number {
|
|||||||
return Math.abs(hash) % 360;
|
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 [metaOpen, setMetaOpen] = useState(false);
|
||||||
const isAgent = message.user.role === "agent";
|
const isAgent = message.user.role === "agent";
|
||||||
const isDeleted = !!message.deleted_at;
|
const isDeleted = !!message.deleted_at;
|
||||||
@@ -77,7 +92,8 @@ export function MessageItem({ message, replyTarget, onSelect, onDelete, currentU
|
|||||||
id={`msg-${message.id}`}
|
id={`msg-${message.id}`}
|
||||||
onClick={() => onSelect(message.id)}
|
onClick={() => onSelect(message.id)}
|
||||||
className={cn(
|
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,
|
cfg.border,
|
||||||
selected ? "!border-l-primary bg-primary/5" : isAgent ? "bg-card" : "bg-background",
|
selected ? "!border-l-primary bg-primary/5" : isAgent ? "bg-card" : "bg-background",
|
||||||
"hover:bg-muted/30",
|
"hover:bg-muted/30",
|
||||||
@@ -96,9 +112,9 @@ export function MessageItem({ message, replyTarget, onSelect, onDelete, currentU
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="px-4 py-3 md:px-5 md:py-4">
|
<div className={cn("px-4 md:px-5", compact ? "py-0.5 pl-[52px] md:pl-[56px]" : "py-3 md:py-4")}>
|
||||||
{/* Header */}
|
{/* Header — hidden in compact mode */}
|
||||||
<div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
{!compact && <div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
||||||
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
||||||
<Avatar size="sm" className="rounded-none">
|
<Avatar size="sm" className="rounded-none">
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
@@ -151,7 +167,7 @@ export function MessageItem({ message, replyTarget, onSelect, onDelete, currentU
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Floating action pill — top-right, appears on hover */}
|
{/* 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">
|
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user