- 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)
749 lines
25 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.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, '<').replace(/>/g, '>');
|
|
const escapedId = id.replace(/"/g, '"');
|
|
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>
|
|
);
|
|
}
|