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:
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user