click-to-select for multi-reply + /new command + layout fix

- Click any message to select it for reply (click again to deselect)
- Multiple messages can be selected — compose shows all
- Selected messages get orange left border + tinted bg
- Floating pill shows ✓ when selected
- /new <name> slash command creates channels from compose box
- Proper multi-reply context in compose with count

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:36:39 +02:00
parent 518cfc2d95
commit 4a05665d64
3 changed files with 75 additions and 35 deletions

View File

@@ -43,7 +43,7 @@ export default function App() {
const [activeChannelId, setActiveChannelId] = useState<string | null>(null); const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [replyTo, setReplyTo] = useState<{ id: string; username: string; content: string } | null>(null); const [selectedMessages, setSelectedMessages] = useState<{ id: string; username: string; content: string }[]>([]);
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [showScrollDown, setShowScrollDown] = useState(false); const [showScrollDown, setShowScrollDown] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -93,7 +93,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
setMessages([]); setMessages([]);
setReplyTo(null); setSelectedMessages([]);
prevMsgCountRef.current = 0; prevMsgCountRef.current = 0;
loadMessages(); loadMessages();
}, [activeChannelId, loadMessages]); }, [activeChannelId, loadMessages]);
@@ -212,11 +212,15 @@ export default function App() {
message={msg} message={msg}
replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined} replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined}
currentUsername={getCurrentUsername()} currentUsername={getCurrentUsername()}
onReply={(id) => { selected={selectedMessages.some((s) => 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); const target = messagesById.get(id);
if (target) { if (!target) return prev;
setReplyTo({ id, username: target.user.display_name, content: target.content }); return [...prev, { id, username: target.user.display_name, content: target.content }];
} });
}} }}
onDelete={async (chId, msgId) => { onDelete={async (chId, msgId) => {
try { try {
@@ -248,9 +252,11 @@ export default function App() {
<ComposeBox <ComposeBox
key={activeChannelId} key={activeChannelId}
channelId={activeChannelId} channelId={activeChannelId}
replyTo={replyTo} replyTo={selectedMessages.length > 0 ? selectedMessages[0] : null}
onClearReply={() => setReplyTo(null)} selectedMessages={selectedMessages}
onClearReply={() => setSelectedMessages([])}
onMessageSent={() => { onMessageSent={() => {
setSelectedMessages([]);
loadMessages(); loadMessages();
setTimeout(() => scrollToBottom(), 100); setTimeout(() => scrollToBottom(), 100);
}} }}

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import type { MessageType } from "@/types/MessageType"; import type { MessageType } from "@/types/MessageType";
import type { User } from "@/types/User"; import type { User } from "@/types/User";
import { postMessage, getUsers, getCurrentUsername } from "@/api"; import { postMessage, getUsers, getCurrentUsername, createChannel } from "@/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ReplyContext { interface ReplyContext {
@@ -13,6 +13,7 @@ interface ReplyContext {
interface Props { interface Props {
channelId: string; channelId: string;
replyTo: ReplyContext | null; replyTo: ReplyContext | null;
selectedMessages: ReplyContext[];
onClearReply: () => void; onClearReply: () => void;
onMessageSent: () => void; onMessageSent: () => void;
} }
@@ -28,6 +29,7 @@ const TYPE_META: Record<MessageType, { prefix: string; color: string; key: strin
export function ComposeBox({ export function ComposeBox({
channelId, channelId,
replyTo, replyTo,
selectedMessages,
onClearReply, onClearReply,
onMessageSent, onMessageSent,
}: Props) { }: Props) {
@@ -95,11 +97,32 @@ export function ComposeBox({
} }
async function handleSend() { async function handleSend() {
if (!content.trim() || sending) return; const text = content.trim();
if (!text || sending) return;
// Slash commands
if (text.startsWith("/")) {
const parts = text.split(/\s+/);
const cmd = parts[0].toLowerCase();
const arg = parts.slice(1).join(" ").trim();
if (cmd === "/new" && arg) {
setSending(true);
try {
await createChannel({ name: arg, description: "" });
setContent("");
onMessageSent(); // Triggers channel refresh
} finally {
setSending(false);
}
return;
}
}
setSending(true); setSending(true);
try { try {
await postMessage(channelId, { await postMessage(channelId, {
content: content.trim(), content: text,
type: msgType, type: msgType,
reply_to: replyTo?.id ?? undefined, reply_to: replyTo?.id ?? undefined,
}); });
@@ -124,23 +147,29 @@ export function ComposeBox({
"transition-colors px-4 py-4 md:px-6 md:py-5 pb-[env(safe-area-inset-bottom,16px)]", "transition-colors px-4 py-4 md:px-6 md:py-5 pb-[env(safe-area-inset-bottom,16px)]",
focused ? "bg-card" : "bg-background", focused ? "bg-card" : "bg-background",
)}> )}>
{/* Reply chip */} {/* Selected messages for reply */}
{replyTo && ( {selectedMessages.length > 0 && (
<div className="flex items-center gap-2 mb-3 px-3 py-2 border-l-4 border-primary bg-muted/30 text-xs font-mono"> <div className="mb-3 border-l-4 border-primary bg-muted/30">
<div className="flex-1 min-w-0"> <div className="flex items-center justify-between px-3 pt-2 pb-1">
<span className="text-primary font-bold">{replyTo.username}</span> <span className="text-[10px] font-mono text-primary font-bold uppercase tracking-wider">
<span className="text-muted-foreground ml-2 truncate inline-block max-w-48 md:max-w-96 align-bottom"> replying to {selectedMessages.length} {selectedMessages.length === 1 ? "message" : "messages"}
{replyTo.content}
</span> </span>
</div>
<button <button
type="button" type="button"
onClick={onClearReply} onClick={onClearReply}
className="text-muted-foreground hover:text-primary flex-shrink-0 text-sm font-bold min-w-[32px] min-h-[32px] flex items-center justify-center" className="text-muted-foreground hover:text-primary text-sm font-bold"
> >
x ×
</button> </button>
</div> </div>
{selectedMessages.map((msg) => (
<div key={msg.id} className="px-3 py-1 text-xs font-mono flex gap-2 text-muted-foreground">
<span className="text-foreground font-bold flex-shrink-0">{msg.username}</span>
<span className="truncate">{msg.content}</span>
</div>
))}
<div className="h-1" />
</div>
)} )}
{/* Mention autocomplete */} {/* Mention autocomplete */}

View File

@@ -8,9 +8,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
interface Props { interface Props {
message: Message; message: Message;
replyTarget?: Message; replyTarget?: Message;
onReply: (id: string) => void; onSelect: (id: string) => void;
onDelete: (channelId: string, msgId: string) => void; onDelete: (channelId: string, msgId: string) => void;
currentUsername: string; currentUsername: string;
selected: boolean;
} }
const TYPE_CONFIG: Record<string, { border: string; label: string; labelBg: string }> = { const TYPE_CONFIG: Record<string, { border: string; label: string; labelBg: string }> = {
@@ -55,7 +56,7 @@ function userHue(username: string): number {
return Math.abs(hash) % 360; return Math.abs(hash) % 360;
} }
export function MessageItem({ message, replyTarget, onReply, onDelete, currentUsername }: Props) { export function MessageItem({ message, 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;
@@ -74,10 +75,11 @@ export function MessageItem({ message, replyTarget, onReply, onDelete, currentUs
return ( return (
<div <div
id={`msg-${message.id}`} id={`msg-${message.id}`}
onClick={() => onSelect(message.id)}
className={cn( className={cn(
"group border-b border-border/50 border-l-4 transition-all duration-300", "group relative border-b border-border/50 border-l-4 transition-all duration-300 cursor-pointer",
cfg.border, cfg.border,
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",
)} )}
> >
@@ -155,16 +157,19 @@ export function MessageItem({ message, replyTarget, onReply, onDelete, currentUs
<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">
<button <button
type="button" type="button"
onClick={() => onReply(message.id)} onClick={(e) => { e.stopPropagation(); onSelect(message.id); }}
className="px-2.5 py-1.5 text-sm text-muted-foreground hover:text-primary hover:bg-muted/50 transition-colors" className={cn(
title="Reply" "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> </button>
{!isDeleted && message.user.username === currentUsername && ( {!isDeleted && message.user.username === currentUsername && (
<button <button
type="button" type="button"
onClick={() => onDelete(message.channel_id, message.id)} 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" 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" title="Delete"
> >