gitdataai/src/components/room/RoomChatPanel.tsx
ZhenYi b7328e22f3
Some checks are pending
CI / Frontend Build (push) Blocked by required conditions
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
feat(frontend): render mentions as styled buttons in input and messages
- MentionInput: contenteditable div that renders all mention types as
  emoji+label pill spans (🤖 AI, 👤 User, 📦 Repo, 🔔 Notify)
- Single-backspace deletes entire mention at cursor (detects caret
  at mention start boundary)
- Ctrl+Enter sends, plain Enter swallowed, Shift+Enter inserts newline
- Placeholder CSS via data-placeholder attribute
- MessageMentions: emoji button rendering extended to all mention types
  (user, repository, notify) with click-to-insert support
- Rich input synced via cursorRef (no stale-state re-renders)
2026-04-18 01:06:39 +08:00

749 lines
25 KiB
TypeScript

import type { ProjectRepositoryItem, RoomResponse, RoomMemberResponse, RoomMessageResponse, RoomThreadResponse } from '@/client';
import { useRoom, type MessageWithMeta } from '@/contexts';
import { type RoomAiConfig } from '@/contexts/room-context';
import { useRoomDraft } from '@/hooks/useRoomDraft';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { buildMentionHtml } from '@/lib/mention-ast';
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
import { MentionInput } from './MentionInput';
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
import {
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type ReactNode,
} from 'react';
import { toast } from 'sonner';
import { RoomAiTasksPanel } from './RoomAiTasksPanel';
import { MentionPopover } from './MentionPopover';
import { RoomMessageEditDialog } from './RoomMessageEditDialog';
import { RoomMessageEditHistoryDialog } from './RoomMessageEditHistoryDialog';
import { RoomMessageList } from './RoomMessageList';
import { RoomParticipantsPanel } from './RoomParticipantsPanel';
import { RoomSettingsPanel } from './RoomSettingsPanel';
import { RoomMessageSearch } from './RoomMessageSearch';
import { RoomMentionPanel } from './RoomMentionPanel';
import { RoomThreadPanel } from './RoomThreadPanel';
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
export interface ChatInputAreaHandle {
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
/** Insert a category prefix (e.g. 'ai') into the textarea and trigger React onChange */
insertCategory: (category: string) => void;
}
interface ChatInputAreaProps {
roomName: string;
onSend: (content: string) => void;
isSending: boolean;
members: RoomMemberResponse[];
repos?: ProjectRepositoryItem[];
reposLoading?: boolean;
aiConfigs?: RoomAiConfig[];
aiConfigsLoading?: boolean;
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
draft: string;
onDraftChange: (content: string) => void;
onClearDraft: () => void;
ref?: React.Ref<ChatInputAreaHandle>;
}
const ChatInputArea = memo(function ChatInputArea({
roomName,
onSend,
isSending,
members,
repos,
reposLoading,
aiConfigs,
aiConfigsLoading,
replyingTo,
onCancelReply,
draft,
onDraftChange,
onClearDraft,
ref,
}: ChatInputAreaProps) {
const containerRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef(0);
const [showMentionPopover, setShowMentionPopover] = useState(false);
const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => {
if (!containerRef.current) return;
const container = containerRef.current;
const cursorPos = cursorRef.current;
const textBefore = draft.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = cursorPos - fullMatch.length;
const before = draft.substring(0, startPos);
const after = draft.substring(cursorPos);
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
if (!suggestion || suggestion.type !== 'item') return;
const html = buildMentionHtml(
suggestion.category!,
suggestion.mentionId!,
suggestion.label,
);
const spacer = ' ';
const newValue = before + html + spacer + after;
const newCursorPos = startPos + html.length + spacer.length;
onDraftChange(newValue);
setShowMentionPopover(false);
setTimeout(() => {
const container = containerRef.current;
if (!container) return;
container.innerHTML = newValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
// Place cursor at newCursorPos
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
let charCount = 0;
let found = false;
function walk(node: Node) {
if (found) return;
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent ?? '';
if (charCount + t.length >= newCursorPos) {
range.setStart(node, Math.min(newCursorPos - charCount, t.length));
range.collapse(true);
found = true;
return;
}
charCount += t.length;
} else if (node.nodeType === Node.ELEMENT_NODE) {
for (const c of Array.from(node.childNodes)) {
walk(c);
if (found) return;
}
}
}
walk(container);
if (!found) { range.selectNodeContents(container); range.collapse(false); }
sel.removeAllRanges();
sel.addRange(range);
container.focus();
}, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Uses draft from closure
useImperativeHandle(ref, () => ({
insertMention: (id: string, label: string) => {
const cursorPos = cursorRef.current;
const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedId = id.replace(/"/g, '&quot;');
const mentionText = `<mention type="user" id="${escapedId}">${escapedLabel}</mention> `;
const before = draft.substring(0, cursorPos);
const after = draft.substring(cursorPos);
const newValue = before + mentionText + after;
onDraftChange(newValue);
setShowMentionPopover(false);
setTimeout(() => {
containerRef.current?.focus();
}, 0);
},
insertCategory: (category: string) => {
const cursorPos = cursorRef.current;
const textBefore = draft.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = cursorPos - fullMatch.length;
const before = draft.substring(0, startPos);
const afterPartial = draft.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1;
onDraftChange(newValue);
setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN));
},
}));
// Listen for mention-click events from message content (e.g. 🤖 AI button)
useEffect(() => {
const onMentionClick = (e: Event) => {
const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail;
const cursorPos = cursorRef.current;
const textBefore = draft.substring(0, cursorPos);
const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label);
const spacer = ' ';
const newValue = textBefore + html + spacer + draft.substring(cursorPos);
const newCursorPos = cursorPos + html.length + spacer.length;
onDraftChange(newValue);
setTimeout(() => {
const container = containerRef.current;
if (!container) return;
container.focus();
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
let charCount = 0;
let found = false;
function walk(node: Node) {
if (found) return;
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent ?? '';
if (charCount + t.length >= newCursorPos) {
range.setStart(node, Math.min(newCursorPos - charCount, t.length));
range.collapse(true);
found = true;
return;
}
charCount += t.length;
} else if (node.nodeType === Node.ELEMENT_NODE) {
for (const c of Array.from(node.childNodes)) {
walk(c);
if (found) return;
}
}
}
walk(container);
if (!found) { range.selectNodeContents(container); range.collapse(false); }
sel.removeAllRanges();
sel.addRange(range);
}, 0);
};
document.addEventListener('mention-click', onMentionClick);
return () => document.removeEventListener('mention-click', onMentionClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="border-t border-border/70 bg-background p-3">
{replyingTo && (
<div className="mb-2 flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-xs">
<span className="font-medium text-foreground">Replying to {replyingTo.display_name}</span>
<span className="truncate text-muted-foreground" title={replyingTo.content}>{replyingTo.content.length > 80 ? replyingTo.content.slice(0, 80) + '…' : replyingTo.content}</span>
<button onClick={onCancelReply} className="ml-auto text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</div>
)}
<div className="relative">
<MentionInput
ref={containerRef}
value={draft}
cursorRef={cursorRef}
onChange={(v) => {
onDraftChange(v);
const textBefore = v.substring(0, cursorRef.current);
if (textBefore.match(MENTION_PATTERN)) {
setShowMentionPopover(true);
} else {
setShowMentionPopover(false);
}
}}
onSend={() => {
const content = draft.trim();
if (content && !isSending) {
onSend(content);
onClearDraft();
}
}}
placeholder={`Message #${roomName}...`}
/>
<div className="absolute bottom-2 right-2 flex items-center gap-1">
<Button
size="sm"
onClick={() => {
const content = draft.trim();
if (content && !isSending) {
onSend(content);
onClearDraft();
}
}}
disabled={isSending}
className="h-7 px-3"
>
<Send className="mr-1 h-3 w-3" />
Send
</Button>
</div>
</div>
{showMentionPopover && (
<MentionPopover
members={members}
repos={repos}
reposLoading={reposLoading}
aiConfigs={aiConfigs}
aiConfigsLoading={aiConfigsLoading}
inputValue={draft}
cursorPosition={cursorRef.current}
onSelect={handleMentionSelect}
textareaRef={containerRef}
onOpenChange={setShowMentionPopover}
onCategoryEnter={(category: string) => {
const cursorPos = cursorRef.current;
const textBefore = draft.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = cursorPos - fullMatch.length;
const before = draft.substring(0, startPos);
const afterPartial = draft.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1;
onDraftChange(newValue);
setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN));
}}
/>
)}
</div>
);
});
/** Animated slide panel - handles enter/exit animations */
function SlidePanel({
open,
width = 'w-[360px]',
children,
}: {
open: boolean;
width?: string;
children: ReactNode;
}) {
const [visible, setVisible] = useState(open);
const [animate, setAnimate] = useState(open);
useEffect(() => {
if (open) {
setVisible(true);
// Force reflow then animate in
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setAnimate(true);
});
});
} else {
setAnimate(false);
const timer = setTimeout(() => setVisible(false), 200);
return () => clearTimeout(timer);
}
}, [open]);
if (!visible) return null;
return (
<aside
className={`${width} border-l border-border/70 bg-card/50 transition-all duration-200 ease-out ${
animate ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
}`}
style={{ flexShrink: 0 }}
>
{children}
</aside>
);
}
interface RoomChatPanelProps {
room: RoomResponse;
isAdmin: boolean;
onClose: () => void;
onDelete: () => void;
}
export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPanelProps) {
const {
messages,
members,
membersLoading,
sendMessage,
editMessage,
revokeMessage,
updateRoom,
wsStatus,
wsError,
wsClient,
threads,
refreshThreads,
projectRepos,
reposLoading,
roomAiConfigs,
aiConfigsLoading,
} = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputAreaHandle>(null);
const [replyingTo, setReplyingTo] = useState<MessageWithMeta | null>(null);
const [editingMessage, setEditingMessage] = useState<MessageWithMeta | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editHistoryDialogOpen, setEditHistoryDialogOpen] = useState(false);
const [selectedMessageForHistory, setSelectedMessageForHistory] = useState<string>('');
const [showAiTasks, setShowAiTasks] = useState(false);
const [showParticipants, setShowParticipants] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showMentions, setShowMentions] = useState(false);
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
// Draft management
const { draft, setDraft, clearDraft } = useRoomDraft(room.id);
const isWsConnected = wsStatus === 'open';
const connectionLabel = wsStatus === 'connecting'
? 'Connecting...'
: wsStatus === 'closed'
? 'Disconnected'
: wsStatus === 'error'
? (wsError ?? 'Connection error')
: null;
// Visual connection status dot (Discord-style)
const statusDotColor = wsStatus === 'open'
? 'bg-green-500'
: wsStatus === 'connecting'
? 'bg-yellow-400 animate-pulse'
: 'bg-red-500';
const handleSend = useCallback(
(content: string) => {
sendMessage(content, 'text', replyingTo?.id ?? undefined);
setReplyingTo(null);
},
// sendMessage from useRoom is already stable; replyingTo changes trigger handleSend rebuild (acceptable)
[sendMessage, replyingTo],
);
const handleEdit = useCallback((message: MessageWithMeta) => {
setEditingMessage(message);
setEditDialogOpen(true);
}, []);
const handleViewEditHistory = useCallback((message: MessageWithMeta) => {
setSelectedMessageForHistory(message.id);
setEditHistoryDialogOpen(true);
}, []);
const handleEditConfirm = useCallback(
(newContent: string) => {
if (!editingMessage) return;
editMessage(editingMessage.id, newContent);
setEditDialogOpen(false);
setEditingMessage(null);
toast.success('Message updated');
},
// Only rebuild when editingMessage.id actually changes, not on every new message
[editingMessage?.id, editMessage],
);
const handleRevoke = useCallback(
(message: MessageWithMeta) => {
revokeMessage(message.id);
toast.success('Message deleted');
},
[revokeMessage],
);
// Stable: chatInputRef is stable, no deps that change on message updates
const handleMention = useCallback((id: string, label: string) => {
chatInputRef.current?.insertMention(id, label, 'user');
}, []);
const handleSelectSearchResult = useCallback((message: RoomMessageResponse) => {
toast.info(`Selected message from ${message.send_at}`);
setShowSearch(false);
}, []);
const handleUpdateRoom = useCallback(
async (name: string, isPublic: boolean) => {
setIsUpdatingRoom(true);
try {
await updateRoom(room.id, name, isPublic);
} finally {
setIsUpdatingRoom(false);
}
},
[room.id, updateRoom],
);
// Thread callbacks
const handleOpenThread = useCallback((message: MessageWithMeta) => {
if (!message.thread_id) return;
const thread = threads.find(t => t.id === message.thread_id);
if (thread) {
setActiveThread({ thread, parentMessage: message });
}
}, [threads]);
const handleCreateThread = useCallback(async (message: MessageWithMeta) => {
if (!wsClient || message.thread_id) return;
try {
const thread = await wsClient.threadCreate(room.id, message.seq);
setActiveThread({ thread, parentMessage: message });
refreshThreads();
} catch (err) {
console.error('Failed to create thread:', err);
toast.error('Failed to create thread');
}
}, [wsClient, room.id, refreshThreads]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
useEffect(() => {
setReplyingTo(null);
setEditingMessage(null);
setEditDialogOpen(false);
setShowAiTasks(false);
setShowParticipants(false);
setShowSettings(false);
setShowSearch(false);
setShowMentions(false);
setActiveThread(null);
}, [room.id]);
return (
<section className="flex h-full min-w-0 flex-1 bg-background">
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-12 items-center justify-between border-b border-border/70 px-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Hash className="h-5 w-5 text-muted-foreground" />
<h1 className="truncate text-base font-semibold text-foreground">{room.room_name}</h1>
<span
className={cn('h-2 w-2 rounded-full', statusDotColor)}
title={connectionLabel ?? 'Connected'}
/>
{!room.public && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
Private
</span>
)}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{members.length} member{members.length !== 1 ? 's' : ''}
{messages.length > 0 && ` · ${messages.length} message${messages.length !== 1 ? 's' : ''}`}
{!isWsConnected && connectionLabel && (
<span className="ml-2"> {connectionLabel}</span>
)}
</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', showMentions && 'bg-muted text-foreground')}
onClick={() => {
setShowMentions((v) => !v);
setShowAiTasks(false);
setShowParticipants(false);
setShowSettings(false);
setShowSearch(false);
}}
title="@ Mentions"
>
<Bell className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', showSearch && 'bg-muted text-foreground')}
onClick={() => {
setShowSearch((v) => !v);
setShowAiTasks(false);
setShowParticipants(false);
setShowSettings(false);
setShowMentions(false);
}}
title="Search messages"
>
<Search className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', showAiTasks && 'bg-muted text-foreground')}
onClick={() => {
setShowAiTasks((v) => !v);
setShowParticipants(false);
setShowSettings(false);
setShowSearch(false);
setShowMentions(false);
}}
title="AI tasks"
>
<Timer className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', showParticipants && 'bg-muted text-foreground')}
onClick={() => {
setShowParticipants((v) => !v);
setShowAiTasks(false);
setShowSettings(false);
setShowSearch(false);
setShowMentions(false);
}}
title="Members"
>
<Users className="h-4 w-4" />
</Button>
{isAdmin && (
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', showSettings && 'bg-muted text-foreground')}
onClick={() => {
setShowSettings((v) => !v);
setShowAiTasks(false);
setShowParticipants(false);
setShowSearch(false);
setShowMentions(false);
}}
title="Room settings"
>
<Settings className="h-4 w-4" />
</Button>
)}
{isAdmin && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={onDelete}
title="Delete room"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="sm" className="h-8 md:hidden" onClick={onClose}>
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col">
<RoomMessageList
roomId={room.id}
messages={messages}
messagesEndRef={messagesEndRef}
onInlineEdit={handleEdit}
onViewHistory={handleViewEditHistory}
onRevoke={handleRevoke}
onReply={setReplyingTo}
onMention={handleMention}
onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread}
/>
</div>
<ChatInputArea
ref={chatInputRef}
roomName={room.room_name ?? 'room'}
onSend={handleSend}
isSending={false}
members={members}
repos={projectRepos}
reposLoading={reposLoading}
aiConfigs={roomAiConfigs}
aiConfigsLoading={aiConfigsLoading}
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
onCancelReply={() => setReplyingTo(null)}
draft={draft}
onDraftChange={setDraft}
onClearDraft={clearDraft}
/>
</div>
{/* Side panels with slide animations */}
<SlidePanel open={showParticipants} width="w-[360px]">
<RoomParticipantsPanel
members={members}
membersLoading={membersLoading}
onMention={handleMention}
/>
</SlidePanel>
<SlidePanel open={showMentions} width="w-[380px]">
<RoomMentionPanel
onClose={() => setShowMentions(false)}
onSelectNotification={(mention) => {
toast.info(`Navigate to message in ${mention.room_name}`);
setShowMentions(false);
}}
/>
</SlidePanel>
<SlidePanel open={showSearch} width="w-[400px]">
<RoomMessageSearch
roomId={room.id}
onSelectMessage={handleSelectSearchResult}
onClose={() => setShowSearch(false)}
/>
</SlidePanel>
<SlidePanel open={showAiTasks} width="w-72">
<RoomAiTasksPanel
roomId={room.id}
onClose={() => setShowAiTasks(false)}
/>
</SlidePanel>
<SlidePanel open={showSettings && isAdmin} width="w-[380px]">
<RoomSettingsPanel
room={room}
onUpdate={handleUpdateRoom}
onClose={() => setShowSettings(false)}
isPending={isUpdatingRoom}
/>
</SlidePanel>
{activeThread && (
<RoomThreadPanel
roomId={room.id}
thread={activeThread.thread}
parentMessage={activeThread.parentMessage}
onClose={() => setActiveThread(null)}
/>
)}
<RoomMessageEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
originalContent={editingMessage?.content ?? ''}
onConfirm={handleEditConfirm}
/>
<RoomMessageEditHistoryDialog
open={editHistoryDialogOpen}
onOpenChange={setEditHistoryDialogOpen}
messageId={selectedMessageForHistory}
roomId={room.id}
/>
</section>
);
}