1171 lines
47 KiB
TypeScript
1171 lines
47 KiB
TypeScript
import {
|
|
mdiArchiveOutline,
|
|
mdiArrowRight,
|
|
mdiChatOutline,
|
|
mdiClose,
|
|
mdiCogOutline,
|
|
mdiDeleteOutline,
|
|
mdiMenu,
|
|
mdiOpenInNew,
|
|
mdiPencilOutline,
|
|
mdiPlus,
|
|
} from '@mdi/js';
|
|
import axios from 'axios';
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/router';
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import BaseIcon from '../BaseIcon';
|
|
import { hasPermission } from '../../helpers/userPermissions';
|
|
import { useAppSelector } from '../../stores/hooks';
|
|
import ChatMarkdown from './ChatMarkdown';
|
|
|
|
type AgentSummary = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
model: string;
|
|
is_default?: boolean;
|
|
};
|
|
|
|
type WorkspaceMessage = {
|
|
id: string;
|
|
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
content?: string;
|
|
content_markdown?: string;
|
|
delivery_status?: string;
|
|
createdAt?: string;
|
|
sent_at?: string;
|
|
completed_at?: string;
|
|
sequence?: number;
|
|
optimistic?: boolean;
|
|
pending?: boolean;
|
|
};
|
|
|
|
type ConversationSummary = {
|
|
id: string;
|
|
title: string;
|
|
summary?: string | null;
|
|
status: 'active' | 'archived' | 'deleted';
|
|
is_pinned?: boolean;
|
|
last_message_at?: string | null;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
agent?: AgentSummary | null;
|
|
};
|
|
|
|
type ConversationDetail = ConversationSummary & {
|
|
client_context_json?: string | null;
|
|
messages: WorkspaceMessage[];
|
|
};
|
|
|
|
type Notice = {
|
|
type: 'success' | 'error';
|
|
message: string;
|
|
};
|
|
|
|
const SUGGESTED_PROMPTS = [
|
|
'Draft a launch checklist for an AI feature release.',
|
|
'Explain how to structure a clean API endpoint with validation.',
|
|
'Create a short spec for a user settings page with profile editing.',
|
|
'Help me plan the next milestone for this workspace product.',
|
|
];
|
|
|
|
const sortConversations = (items: ConversationSummary[]) =>
|
|
[...items].sort((left, right) => {
|
|
if (Boolean(left.is_pinned) !== Boolean(right.is_pinned)) {
|
|
return left.is_pinned ? -1 : 1;
|
|
}
|
|
|
|
const leftDate = new Date(left.last_message_at || left.updatedAt || left.createdAt || 0).getTime();
|
|
const rightDate = new Date(right.last_message_at || right.updatedAt || right.createdAt || 0).getTime();
|
|
|
|
return rightDate - leftDate;
|
|
});
|
|
|
|
const formatSidebarTime = (value?: string | null) => {
|
|
if (!value) {
|
|
return 'New';
|
|
}
|
|
|
|
const date = new Date(value);
|
|
const diff = Date.now() - date.getTime();
|
|
|
|
if (diff < 1000 * 60 * 60 * 24) {
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
}).format(date);
|
|
}
|
|
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
}).format(date);
|
|
};
|
|
|
|
const formatMessageTime = (value?: string) => {
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
}).format(new Date(value));
|
|
};
|
|
|
|
const getErrorMessage = (error: unknown, fallback: string) => {
|
|
if (axios.isAxiosError(error)) {
|
|
if (typeof error.response?.data === 'string') {
|
|
return error.response.data;
|
|
}
|
|
|
|
if (typeof error.response?.data?.message === 'string') {
|
|
return error.response.data.message;
|
|
}
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
|
|
return fallback;
|
|
};
|
|
|
|
const toConversationSummary = (conversation: ConversationDetail): ConversationSummary => ({
|
|
id: conversation.id,
|
|
title: conversation.title,
|
|
summary: conversation.summary,
|
|
status: conversation.status,
|
|
is_pinned: conversation.is_pinned,
|
|
last_message_at: conversation.last_message_at,
|
|
createdAt: conversation.createdAt,
|
|
updatedAt: conversation.updatedAt,
|
|
agent: conversation.agent,
|
|
});
|
|
|
|
function TypingIndicator() {
|
|
return (
|
|
<div className="flex items-center gap-1 text-slate-400">
|
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-slate-300" />
|
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-slate-300 [animation-delay:120ms]" />
|
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-slate-300 [animation-delay:240ms]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type MessageBubbleProps = {
|
|
currentUserName: string;
|
|
message: WorkspaceMessage;
|
|
streamingText: string;
|
|
streamingMessageId: string | null;
|
|
};
|
|
|
|
function MessageBubble({
|
|
currentUserName,
|
|
message,
|
|
streamingText,
|
|
streamingMessageId,
|
|
}: MessageBubbleProps) {
|
|
const isUser = message.role === 'user';
|
|
const isStreaming = streamingMessageId === message.id;
|
|
const isFailed = message.delivery_status === 'failed';
|
|
const rawContent = message.content_markdown || message.content || '';
|
|
const displayTime = formatMessageTime(message.completed_at || message.sent_at || message.createdAt);
|
|
let statusLabel = '';
|
|
|
|
if (message.pending) {
|
|
statusLabel = 'thinking';
|
|
} else if (isStreaming) {
|
|
statusLabel = 'responding';
|
|
} else if (message.optimistic) {
|
|
statusLabel = 'sending';
|
|
}
|
|
|
|
const bubbleClassName = isUser
|
|
? 'border border-slate-900 bg-slate-900 text-white shadow-sm'
|
|
: isFailed
|
|
? 'border border-red-200 bg-red-50 text-red-900 shadow-sm'
|
|
: 'border border-slate-200 bg-white text-slate-900 shadow-sm';
|
|
const avatarLabel = isUser ? currentUserName : 'AI';
|
|
const metaClassName = isUser
|
|
? 'text-[11px] uppercase tracking-[0.18em] text-white/70'
|
|
: isFailed
|
|
? 'text-[11px] uppercase tracking-[0.18em] text-red-500'
|
|
: 'text-[11px] uppercase tracking-[0.18em] text-slate-400';
|
|
|
|
return (
|
|
<div className={`flex gap-2.5 ${isUser ? 'justify-end' : 'justify-start'}`}>
|
|
{!isUser && (
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-slate-100 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
|
|
{avatarLabel}
|
|
</div>
|
|
)}
|
|
<div className={`max-w-[34rem] rounded-[12px] px-3.5 py-2.5 ${bubbleClassName}`}>
|
|
<div className={`mb-1.5 flex flex-wrap items-center gap-1.5 ${metaClassName}`}>
|
|
<span>{isUser ? currentUserName : 'Assistant'}</span>
|
|
{statusLabel && (
|
|
<span
|
|
className={`rounded-md px-2 py-1 ${
|
|
isUser
|
|
? 'bg-white/10 text-white/80'
|
|
: isFailed
|
|
? 'bg-red-100 text-red-600'
|
|
: 'bg-slate-100 text-slate-500'
|
|
}`}
|
|
>
|
|
{statusLabel}
|
|
</span>
|
|
)}
|
|
{displayTime && (
|
|
<span className={isUser ? 'text-white/50' : isFailed ? 'text-red-400' : 'text-slate-400'}>
|
|
{displayTime}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{message.pending ? (
|
|
<TypingIndicator />
|
|
) : isStreaming ? (
|
|
<pre className={`whitespace-pre-wrap font-sans text-[13px] leading-5 ${isUser ? 'text-white' : 'text-slate-700'}`}>{streamingText}</pre>
|
|
) : (
|
|
<ChatMarkdown
|
|
className={
|
|
isUser
|
|
? 'text-white [&_a]:text-white [&_a]:decoration-white/60 [&_blockquote]:border-white/20 [&_blockquote]:bg-white/10 [&_blockquote]:text-white [&_code]:border-white/10 [&_code]:bg-white/15 [&_code]:text-white [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_p]:text-white [&_strong]:text-white'
|
|
: isFailed
|
|
? 'text-red-900 [&_a]:text-red-700 [&_blockquote]:border-red-200 [&_blockquote]:bg-red-100/70 [&_blockquote]:text-red-900 [&_code]:border-red-200 [&_code]:bg-red-100 [&_code]:text-red-900 [&_h1]:text-red-900 [&_h2]:text-red-900 [&_h3]:text-red-900 [&_p]:text-red-900 [&_strong]:text-red-900'
|
|
: ''
|
|
}
|
|
content={rawContent}
|
|
/>
|
|
)}
|
|
</div>
|
|
{isUser && (
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-slate-900 bg-slate-900 text-[10px] font-semibold text-white">
|
|
{avatarLabel.slice(0, 1)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ConversationRowProps = {
|
|
conversation: ConversationSummary;
|
|
isActive: boolean;
|
|
onSelect: (id: string) => void;
|
|
};
|
|
|
|
function ConversationRow({ conversation, isActive, onSelect }: ConversationRowProps) {
|
|
return (
|
|
<button
|
|
className={`w-full rounded-[8px] border px-2.5 py-2 text-left ${
|
|
isActive
|
|
? 'border-slate-900 bg-white text-slate-900'
|
|
: 'border-transparent bg-transparent'
|
|
}`}
|
|
onClick={() => onSelect(conversation.id)}
|
|
type="button"
|
|
>
|
|
<div className="mb-2 flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className={`truncate text-[12px] font-medium ${isActive ? 'text-slate-900' : 'text-slate-900'}`}>{conversation.title}</p>
|
|
<p className={`mt-0.5 text-[9px] uppercase tracking-[0.14em] ${isActive ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
{conversation.agent?.name || 'General assistant'}
|
|
</p>
|
|
</div>
|
|
<span className={`shrink-0 text-[9px] uppercase tracking-[0.12em] ${isActive ? 'text-slate-400' : 'text-slate-400'}`}>
|
|
{formatSidebarTime(conversation.last_message_at || conversation.updatedAt)}
|
|
</span>
|
|
</div>
|
|
<p className={`line-clamp-2 min-h-[1.75rem] text-[11px] leading-4.5 ${isActive ? 'text-slate-600' : 'text-slate-500'}`}>
|
|
{conversation.summary || 'No messages yet. Start with a prompt to generate the first response.'}
|
|
</p>
|
|
{conversation.status === 'archived' && (
|
|
<span className={`mt-1.5 inline-flex rounded-md border px-2 py-0.5 text-[9px] uppercase tracking-[0.12em] ${
|
|
isActive
|
|
? 'border-slate-200 bg-slate-100 text-slate-500'
|
|
: 'border-slate-200 bg-slate-100 text-slate-500'
|
|
}`}>
|
|
Archived
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function WorkspaceShell() {
|
|
const router = useRouter();
|
|
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const didBootstrapRef = useRef(false);
|
|
const { currentUser } = useAppSelector((state) => state.auth);
|
|
|
|
const [agents, setAgents] = useState<AgentSummary[]>([]);
|
|
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
|
const [activeConversation, setActiveConversation] = useState<ConversationDetail | null>(null);
|
|
const [composer, setComposer] = useState('');
|
|
const [selectedAgentId, setSelectedAgentId] = useState('');
|
|
const [notice, setNotice] = useState<Notice | null>(null);
|
|
const [loadingBootstrap, setLoadingBootstrap] = useState(true);
|
|
const [loadingConversation, setLoadingConversation] = useState(false);
|
|
const [sending, setSending] = useState(false);
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
|
const [draftTitle, setDraftTitle] = useState('');
|
|
const [optimisticMessages, setOptimisticMessages] = useState<WorkspaceMessage[]>([]);
|
|
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null);
|
|
const [streamingText, setStreamingText] = useState('');
|
|
const [hasBootstrapped, setHasBootstrapped] = useState(false);
|
|
|
|
const currentConversationId =
|
|
typeof router.query.conversationId === 'string' ? router.query.conversationId : null;
|
|
const currentUserName = currentUser?.firstName || currentUser?.email || 'You';
|
|
const canAccessAdmin = hasPermission(currentUser, 'READ_USERS');
|
|
|
|
const activeConversations = useMemo(
|
|
() => conversations.filter((conversation) => conversation.status !== 'archived'),
|
|
[conversations],
|
|
);
|
|
const archivedConversations = useMemo(
|
|
() => conversations.filter((conversation) => conversation.status === 'archived'),
|
|
[conversations],
|
|
);
|
|
const visibleMessages = useMemo(
|
|
() => [...(activeConversation?.messages || []), ...optimisticMessages],
|
|
[activeConversation?.messages, optimisticMessages],
|
|
);
|
|
|
|
const upsertConversation = useCallback((conversation: ConversationSummary) => {
|
|
setConversations((previous) => {
|
|
const next = previous.filter((item) => item.id !== conversation.id);
|
|
return sortConversations([conversation, ...next]);
|
|
});
|
|
}, []);
|
|
|
|
const removeConversation = useCallback((conversationId: string) => {
|
|
setConversations((previous) => previous.filter((conversation) => conversation.id !== conversationId));
|
|
}, []);
|
|
|
|
const applyConversationPayload = useCallback(
|
|
(conversation: ConversationDetail) => {
|
|
setActiveConversation(conversation);
|
|
setDraftTitle(conversation.title);
|
|
upsertConversation(toConversationSummary(conversation));
|
|
},
|
|
[upsertConversation],
|
|
);
|
|
|
|
const loadBootstrap = useCallback(async () => {
|
|
setLoadingBootstrap(true);
|
|
try {
|
|
const { data } = await axios.get('/workspace/bootstrap');
|
|
setAgents(data.agents || []);
|
|
setConversations(sortConversations(data.conversations || []));
|
|
return data;
|
|
} catch (error) {
|
|
setNotice({
|
|
type: 'error',
|
|
message: getErrorMessage(error, 'Failed to load the workspace.'),
|
|
});
|
|
return { agents: [], conversations: [] };
|
|
} finally {
|
|
setLoadingBootstrap(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadConversation = useCallback(
|
|
async (conversationId: string) => {
|
|
setLoadingConversation(true);
|
|
try {
|
|
const { data } = await axios.get(`/workspace/conversations/${conversationId}`);
|
|
applyConversationPayload(data.conversation);
|
|
} catch (error) {
|
|
setNotice({
|
|
type: 'error',
|
|
message: getErrorMessage(error, 'Failed to load this conversation.'),
|
|
});
|
|
setActiveConversation(null);
|
|
} finally {
|
|
setLoadingConversation(false);
|
|
}
|
|
},
|
|
[applyConversationPayload],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || didBootstrapRef.current) {
|
|
return;
|
|
}
|
|
|
|
didBootstrapRef.current = true;
|
|
|
|
void (async () => {
|
|
await loadBootstrap();
|
|
setHasBootstrapped(true);
|
|
})();
|
|
}, [router.isReady, loadBootstrap, currentConversationId, router]);
|
|
|
|
useEffect(() => {
|
|
if (!hasBootstrapped || !currentConversationId) {
|
|
if (hasBootstrapped && !currentConversationId) {
|
|
setActiveConversation(null);
|
|
setDraftTitle('');
|
|
setOptimisticMessages([]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
void loadConversation(currentConversationId);
|
|
}, [hasBootstrapped, currentConversationId, loadConversation]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedAgentId && agents.length) {
|
|
const defaultAgent = agents.find((agent) => agent.is_default) || agents[0];
|
|
if (defaultAgent) {
|
|
setSelectedAgentId(defaultAgent.id);
|
|
}
|
|
}
|
|
}, [agents, selectedAgentId]);
|
|
|
|
useEffect(() => {
|
|
if (!streamingMessageId) {
|
|
return;
|
|
}
|
|
|
|
const streamedMessage = activeConversation?.messages.find((message) => message.id === streamingMessageId);
|
|
const fullText = streamedMessage?.content_markdown || streamedMessage?.content || '';
|
|
|
|
if (!fullText) {
|
|
setStreamingMessageId(null);
|
|
setStreamingText('');
|
|
return;
|
|
}
|
|
|
|
setStreamingText('');
|
|
let cursor = 0;
|
|
const step = Math.max(12, Math.ceil(fullText.length / 32));
|
|
|
|
const timer = window.setInterval(() => {
|
|
cursor = Math.min(fullText.length, cursor + step);
|
|
setStreamingText(fullText.slice(0, cursor));
|
|
|
|
if (cursor >= fullText.length) {
|
|
window.clearInterval(timer);
|
|
setStreamingMessageId(null);
|
|
}
|
|
}, 18);
|
|
|
|
return () => window.clearInterval(timer);
|
|
}, [activeConversation?.messages, streamingMessageId]);
|
|
|
|
useEffect(() => {
|
|
if (!scrollContainerRef.current) {
|
|
return;
|
|
}
|
|
|
|
scrollContainerRef.current.scrollTo({
|
|
top: scrollContainerRef.current.scrollHeight,
|
|
behavior: 'smooth',
|
|
});
|
|
}, [visibleMessages.length, loadingConversation, streamingText]);
|
|
|
|
const handleNewChat = useCallback(async () => {
|
|
setNotice(null);
|
|
setComposer('');
|
|
setActiveConversation(null);
|
|
setDraftTitle('');
|
|
setIsEditingTitle(false);
|
|
setOptimisticMessages([]);
|
|
setIsSidebarOpen(false);
|
|
await router.push('/workspace');
|
|
composerRef.current?.focus();
|
|
}, [router]);
|
|
|
|
const handleConversationSelect = useCallback(
|
|
async (conversationId: string) => {
|
|
setIsSidebarOpen(false);
|
|
await router.push(`/workspace/${conversationId}`);
|
|
},
|
|
[router],
|
|
);
|
|
|
|
const handleRenameConversation = useCallback(async () => {
|
|
if (!activeConversation) {
|
|
return;
|
|
}
|
|
|
|
const nextTitle = draftTitle.trim();
|
|
if (!nextTitle) {
|
|
setNotice({
|
|
type: 'error',
|
|
message: 'Conversation titles cannot be empty.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { data } = await axios.patch(`/workspace/conversations/${activeConversation.id}`, {
|
|
title: nextTitle,
|
|
});
|
|
applyConversationPayload(data.conversation);
|
|
setIsEditingTitle(false);
|
|
setNotice({
|
|
type: 'success',
|
|
message: 'Conversation renamed.',
|
|
});
|
|
} catch (error) {
|
|
setNotice({
|
|
type: 'error',
|
|
message: getErrorMessage(error, 'Failed to rename the conversation.'),
|
|
});
|
|
}
|
|
}, [activeConversation, applyConversationPayload, draftTitle]);
|
|
|
|
const handleArchiveConversation = useCallback(async () => {
|
|
if (!activeConversation) {
|
|
return;
|
|
}
|
|
|
|
const nextStatus = activeConversation.status === 'archived' ? 'active' : 'archived';
|
|
|
|
try {
|
|
const { data } = await axios.patch(`/workspace/conversations/${activeConversation.id}`, {
|
|
status: nextStatus,
|
|
});
|
|
applyConversationPayload(data.conversation);
|
|
setNotice({
|
|
type: 'success',
|
|
message: nextStatus === 'archived' ? 'Conversation archived.' : 'Conversation restored.',
|
|
});
|
|
} catch (error) {
|
|
setNotice({
|
|
type: 'error',
|
|
message: getErrorMessage(error, 'Failed to update the conversation status.'),
|
|
});
|
|
}
|
|
}, [activeConversation, applyConversationPayload]);
|
|
|
|
const handleDeleteConversation = useCallback(async () => {
|
|
if (!activeConversation) {
|
|
return;
|
|
}
|
|
|
|
const confirmed = window.confirm('Delete this conversation? This action can be undone only from the database.');
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
const nextConversation = conversations.find((conversation) => conversation.id !== activeConversation.id);
|
|
|
|
try {
|
|
await axios.delete(`/workspace/conversations/${activeConversation.id}`);
|
|
removeConversation(activeConversation.id);
|
|
setNotice({
|
|
type: 'success',
|
|
message: 'Conversation deleted.',
|
|
});
|
|
|
|
if (nextConversation) {
|
|
await router.replace(`/workspace/${nextConversation.id}`);
|
|
} else {
|
|
await router.replace('/workspace');
|
|
}
|
|
} catch (error) {
|
|
setNotice({
|
|
type: 'error',
|
|
message: getErrorMessage(error, 'Failed to delete the conversation.'),
|
|
});
|
|
}
|
|
}, [activeConversation, conversations, removeConversation, router]);
|
|
|
|
const handleAgentChange = useCallback(
|
|
async (nextAgentId: string) => {
|
|
setSelectedAgentId(nextAgentId);
|
|
|
|
if (!activeConversation) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { data } = await axios.patch(`/workspace/conversations/${activeConversation.id}`, {
|
|
agentId: nextAgentId,
|
|
});
|
|
applyConversationPayload(data.conversation);
|
|
} catch (error) {
|
|
setNotice({
|
|
type: 'error',
|
|
message: getErrorMessage(error, 'Failed to change the active agent.'),
|
|
});
|
|
}
|
|
},
|
|
[activeConversation, applyConversationPayload],
|
|
);
|
|
|
|
const handleSendMessage = useCallback(async () => {
|
|
const messageToSend = composer.trim();
|
|
|
|
if (!messageToSend || sending) {
|
|
return;
|
|
}
|
|
|
|
setNotice(null);
|
|
setSending(true);
|
|
setComposer('');
|
|
|
|
let conversation = activeConversation;
|
|
let createdConversationId: string | null = null;
|
|
|
|
try {
|
|
if (!conversation) {
|
|
const { data } = await axios.post('/workspace/conversations', {
|
|
agentId: selectedAgentId || undefined,
|
|
});
|
|
conversation = data.conversation;
|
|
createdConversationId = conversation.id;
|
|
applyConversationPayload(conversation);
|
|
}
|
|
|
|
const optimisticBase = Date.now();
|
|
setOptimisticMessages([
|
|
{
|
|
id: `optimistic-user-${optimisticBase}`,
|
|
role: 'user',
|
|
content_markdown: messageToSend,
|
|
createdAt: new Date().toISOString(),
|
|
optimistic: true,
|
|
},
|
|
{
|
|
id: `optimistic-assistant-${optimisticBase}`,
|
|
role: 'assistant',
|
|
content_markdown: '',
|
|
createdAt: new Date().toISOString(),
|
|
pending: true,
|
|
},
|
|
]);
|
|
|
|
const { data } = await axios.post(`/workspace/conversations/${conversation.id}/messages`, {
|
|
content: messageToSend,
|
|
agentId: conversation.agent?.id || selectedAgentId || undefined,
|
|
});
|
|
|
|
setOptimisticMessages([]);
|
|
applyConversationPayload(data.conversation);
|
|
|
|
const newestAssistant = [...data.conversation.messages]
|
|
.reverse()
|
|
.find((message: WorkspaceMessage) => message.role === 'assistant');
|
|
|
|
if (newestAssistant?.id && newestAssistant.delivery_status !== 'failed') {
|
|
setStreamingMessageId(newestAssistant.id);
|
|
}
|
|
|
|
if (data.ai_error) {
|
|
setNotice({
|
|
type: 'error',
|
|
message: data.ai_error,
|
|
});
|
|
}
|
|
|
|
if (router.asPath !== `/workspace/${data.conversation.id}`) {
|
|
await router.replace(`/workspace/${data.conversation.id}`);
|
|
}
|
|
} catch (error) {
|
|
setOptimisticMessages([]);
|
|
setComposer(messageToSend);
|
|
setNotice({
|
|
type: 'error',
|
|
message: getErrorMessage(error, 'Failed to send the message.'),
|
|
});
|
|
|
|
if (createdConversationId) {
|
|
upsertConversation(toConversationSummary(conversation as ConversationDetail));
|
|
if (router.asPath !== `/workspace/${createdConversationId}`) {
|
|
await router.replace(`/workspace/${createdConversationId}`);
|
|
}
|
|
}
|
|
} finally {
|
|
setSending(false);
|
|
composerRef.current?.focus();
|
|
}
|
|
}, [
|
|
activeConversation,
|
|
applyConversationPayload,
|
|
composer,
|
|
router,
|
|
selectedAgentId,
|
|
sending,
|
|
upsertConversation,
|
|
]);
|
|
|
|
const agentSelectionValue = activeConversation?.agent?.id || selectedAgentId;
|
|
const emptyStateAgent = agents.find((agent) => agent.id === agentSelectionValue) || agents[0];
|
|
const activeConversationTimestamp = activeConversation?.last_message_at || activeConversation?.updatedAt;
|
|
const activeConversationMessageCount = activeConversation?.messages.length || 0;
|
|
|
|
return (
|
|
<section className="h-[calc(100vh-3.5rem)] bg-[#f5f5f7] px-0 sm:px-1.5 sm:pb-1.5 sm:pt-1.5">
|
|
<div className="grid h-full min-h-0 overflow-hidden border border-slate-200 bg-white shadow-[0_16px_48px_-40px_rgba(15,23,42,0.18)] sm:rounded-[10px] lg:grid-cols-[220px_minmax(0,1fr)]">
|
|
<aside
|
|
className={`absolute inset-y-0 left-0 z-20 flex w-full max-w-[220px] flex-col border-r border-slate-200 bg-[#fafafa] transition duration-200 lg:static lg:translate-x-0 ${
|
|
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
|
}`}
|
|
>
|
|
<div className="border-b border-slate-200 px-3.5 py-3.5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
|
Conversations
|
|
</p>
|
|
<h2 className="mt-1 text-[15px] font-semibold text-slate-900">AI workspace</h2>
|
|
</div>
|
|
<button
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 lg:hidden"
|
|
onClick={() => setIsSidebarOpen(false)}
|
|
type="button"
|
|
>
|
|
<BaseIcon path={mdiClose} size={18} />
|
|
</button>
|
|
</div>
|
|
<button
|
|
className="mt-3 inline-flex w-full items-center justify-center gap-1.5 rounded-[8px] bg-slate-900 px-3.5 py-2 text-[13px] font-medium text-white"
|
|
onClick={() => void handleNewChat()}
|
|
type="button"
|
|
>
|
|
<BaseIcon path={mdiPlus} size={18} />
|
|
New chat
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 px-3.5 py-2 text-[9px] uppercase tracking-[0.14em] text-slate-400">
|
|
<span>{conversations.length} chats</span>
|
|
<span className="h-1 w-1 rounded-full bg-slate-300" />
|
|
<span>{activeConversations.length} active</span>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-3 overflow-y-auto px-2.5 pb-3.5">
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between px-1">
|
|
<p className="text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">
|
|
Recent
|
|
</p>
|
|
</div>
|
|
{activeConversations.length ? (
|
|
activeConversations.map((conversation) => (
|
|
<ConversationRow
|
|
conversation={conversation}
|
|
isActive={activeConversation?.id === conversation.id}
|
|
key={conversation.id}
|
|
onSelect={(id) => void handleConversationSelect(id)}
|
|
/>
|
|
))
|
|
) : (
|
|
<div className="rounded-[10px] border border-dashed border-slate-200 bg-white px-2.5 py-3 text-[11px] leading-4.5 text-slate-500">
|
|
Your recent chats will appear here after the first message.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2.5">
|
|
<button
|
|
className="flex w-full items-center justify-between rounded-[8px] border border-slate-200 bg-white px-2.5 py-2 text-left text-[10px] font-medium uppercase tracking-[0.14em] text-slate-500"
|
|
onClick={() => setShowArchived((previous) => !previous)}
|
|
type="button"
|
|
>
|
|
<span>Archived chats</span>
|
|
<span>{showArchived ? 'Hide' : 'Show'}</span>
|
|
</button>
|
|
{showArchived &&
|
|
(archivedConversations.length ? (
|
|
archivedConversations.map((conversation) => (
|
|
<ConversationRow
|
|
conversation={conversation}
|
|
isActive={activeConversation?.id === conversation.id}
|
|
key={conversation.id}
|
|
onSelect={(id) => void handleConversationSelect(id)}
|
|
/>
|
|
))
|
|
) : (
|
|
<div className="rounded-[10px] border border-dashed border-slate-200 bg-white px-2.5 py-3 text-[11px] leading-4.5 text-slate-500">
|
|
Archived chats stay here until you restore them.
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{isSidebarOpen && (
|
|
<button
|
|
aria-label="Close conversation sidebar"
|
|
className="absolute inset-0 z-10 bg-slate-900/40 lg:hidden"
|
|
onClick={() => setIsSidebarOpen(false)}
|
|
type="button"
|
|
/>
|
|
)}
|
|
|
|
<div className="flex min-h-0 min-w-0 flex-col bg-white">
|
|
<div className="border-b border-slate-200 px-3.5 py-2 md:px-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h1 className="truncate text-[15px] font-semibold tracking-[-0.03em] text-slate-900 md:text-[1.02rem]">
|
|
{activeConversation?.title || 'Start a focused conversation'}
|
|
</h1>
|
|
<p className="mt-0.5 text-[11px] text-slate-500">
|
|
{activeConversation
|
|
? `Updated ${formatSidebarTime(activeConversationTimestamp)}`
|
|
: 'Pick an agent, send the first message, and the thread appears on the left automatically.'}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[13px] font-medium text-slate-700 lg:hidden"
|
|
onClick={() => setIsSidebarOpen(true)}
|
|
type="button"
|
|
>
|
|
<BaseIcon path={mdiMenu} size={18} />
|
|
History
|
|
</button>
|
|
<Link
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[13px] font-medium text-slate-700"
|
|
href="/profile"
|
|
>
|
|
<BaseIcon path={mdiCogOutline} size={18} />
|
|
Settings
|
|
</Link>
|
|
{canAccessAdmin && (
|
|
<Link
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[13px] font-medium text-slate-700"
|
|
href="/dashboard"
|
|
>
|
|
<BaseIcon path={mdiOpenInNew} size={18} />
|
|
Backoffice
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
{notice && (
|
|
<div className="border-b border-slate-200 px-3.5 py-2.5 md:px-4">
|
|
<div
|
|
className={`rounded-md border px-2.5 py-2 text-[13px] ${
|
|
notice.type === 'error'
|
|
? 'border-red-200 bg-red-50 text-red-700'
|
|
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
}`}
|
|
>
|
|
{notice.message}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeConversation ? (
|
|
<>
|
|
<div className="border-b border-slate-200 px-3.5 py-2 md:px-4">
|
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="mb-1.5 flex flex-wrap items-center gap-1.5">
|
|
<span className="inline-flex rounded-md border border-slate-200 bg-slate-50 px-2.5 py-1 text-[10px] uppercase tracking-[0.18em] text-slate-500">
|
|
{activeConversation.status === 'archived' ? 'Archived' : 'Active'}
|
|
</span>
|
|
<span className="inline-flex rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[10px] uppercase tracking-[0.18em] text-slate-500">
|
|
{activeConversation.agent?.name || 'General assistant'}
|
|
</span>
|
|
<span className="inline-flex rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[10px] uppercase tracking-[0.18em] text-slate-500">
|
|
{activeConversationMessageCount} messages
|
|
</span>
|
|
</div>
|
|
{isEditingTitle ? (
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<input
|
|
className="w-full rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[14px] font-semibold text-slate-900 outline-none ring-0 placeholder:text-slate-400 focus:border-slate-900"
|
|
onChange={(event) => setDraftTitle(event.target.value)}
|
|
placeholder="Give this conversation a title"
|
|
value={draftTitle}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white"
|
|
onClick={() => void handleRenameConversation()}
|
|
type="button"
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
className="rounded-md border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600"
|
|
onClick={() => {
|
|
setDraftTitle(activeConversation.title);
|
|
setIsEditingTitle(false);
|
|
}}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<h2 className="truncate text-[15px] font-semibold tracking-[-0.03em] text-slate-900">
|
|
{activeConversation.title}
|
|
</h2>
|
|
<button
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500"
|
|
onClick={() => setIsEditingTitle(true)}
|
|
type="button"
|
|
>
|
|
<BaseIcon path={mdiPencilOutline} size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<p className="mt-0.5 max-w-3xl text-[11px] leading-4.5 text-slate-500">
|
|
{activeConversation.summary || activeConversation.agent?.description || 'Keep the thread moving.'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] xl:w-[20rem]">
|
|
<label className="space-y-1 rounded-[10px] border border-slate-200 bg-slate-50 px-2.5 py-2">
|
|
<span className="block text-[9px] uppercase tracking-[0.14em] text-slate-400">Agent</span>
|
|
<select
|
|
className="w-full bg-transparent text-[12px] text-slate-900 outline-none"
|
|
onChange={(event) => void handleAgentChange(event.target.value)}
|
|
value={agentSelectionValue}
|
|
>
|
|
{agents.map((agent) => (
|
|
<option className="bg-white text-slate-900" key={agent.id} value={agent.id}>
|
|
{agent.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<div className="flex flex-wrap items-center justify-end gap-1.5 rounded-[10px] border border-slate-200 bg-slate-50 px-2 py-1.5">
|
|
<button
|
|
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[12px] font-medium text-slate-700"
|
|
onClick={() => void handleArchiveConversation()}
|
|
type="button"
|
|
>
|
|
<BaseIcon path={mdiArchiveOutline} size={16} />
|
|
{activeConversation.status === 'archived' ? 'Restore' : 'Archive'}
|
|
</button>
|
|
<button
|
|
className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-white px-2.5 py-1 text-[12px] font-medium text-red-600"
|
|
onClick={() => void handleDeleteConversation()}
|
|
type="button"
|
|
>
|
|
<BaseIcon path={mdiDeleteOutline} size={16} />
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="flex-1 overflow-y-auto bg-[#fcfcfd] px-3.5 py-3.5 md:px-4" ref={scrollContainerRef}>
|
|
{loadingConversation ? (
|
|
<div className="space-y-4">
|
|
<div className="h-28 rounded-[12px] border border-slate-200 bg-white" />
|
|
<div className="ml-auto h-24 max-w-xl rounded-[12px] border border-slate-900 bg-slate-900/10" />
|
|
<div className="h-40 rounded-[12px] border border-slate-200 bg-white" />
|
|
</div>
|
|
) : visibleMessages.length ? (
|
|
<div className="space-y-4">
|
|
{visibleMessages.map((message) => (
|
|
<MessageBubble
|
|
currentUserName={currentUserName}
|
|
key={message.id}
|
|
message={message}
|
|
streamingMessageId={streamingMessageId}
|
|
streamingText={streamingText}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="mx-auto flex max-w-3xl flex-col items-center rounded-[14px] border border-dashed border-slate-200 bg-white px-6 py-12 text-center">
|
|
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-100 text-slate-600">
|
|
<BaseIcon path={mdiChatOutline} size={28} />
|
|
</div>
|
|
<h3 className="text-2xl font-semibold tracking-[-0.03em] text-slate-900">
|
|
Keep this thread moving.
|
|
</h3>
|
|
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-500 md:text-base">
|
|
Ask a follow-up, switch the agent, or keep refining the work without
|
|
opening a new chat.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-slate-200 bg-white px-3.5 py-2 md:px-4">
|
|
<div className="rounded-[8px] border border-slate-200 bg-white p-2.5">
|
|
<div className="mb-2 flex flex-col gap-1.5 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<p className="text-[9px] uppercase tracking-[0.14em] text-slate-400">Composer</p>
|
|
<p className="mt-0.5 text-[11px] text-slate-500">
|
|
{activeConversation?.status === 'archived'
|
|
? 'Sending a message will reopen this archived conversation.'
|
|
: 'Use Enter to send and Shift+Enter for a new line.'}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-md border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-600">
|
|
{activeConversation?.agent?.name || emptyStateAgent?.name || 'General Assistant'}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2 lg:grid-cols-[minmax(0,1fr)_165px]">
|
|
<textarea
|
|
className="min-h-[62px] w-full resize-none rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[13px] leading-5 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
|
|
onChange={(event) => setComposer(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void handleSendMessage();
|
|
}
|
|
}}
|
|
placeholder="Ask for a draft, plan, code snippet, or product spec…"
|
|
ref={composerRef}
|
|
value={composer}
|
|
/>
|
|
<div className="flex flex-col justify-between gap-2">
|
|
{!activeConversation && (
|
|
<label className="rounded-[10px] border border-slate-200 bg-slate-50 px-2.5 py-2 text-sm text-slate-600">
|
|
<span className="mb-1 block text-[9px] uppercase tracking-[0.14em] text-slate-400">
|
|
Start with agent
|
|
</span>
|
|
<select
|
|
className="w-full bg-transparent text-[12px] text-slate-900 outline-none"
|
|
onChange={(event) => setSelectedAgentId(event.target.value)}
|
|
value={selectedAgentId}
|
|
>
|
|
{agents.map((agent) => (
|
|
<option className="bg-white text-slate-900" key={agent.id} value={agent.id}>
|
|
{agent.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
<button
|
|
className="inline-flex items-center justify-center gap-1.5 rounded-[8px] bg-slate-900 px-3.5 py-2 text-[12px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
|
|
disabled={!composer.trim() || sending}
|
|
onClick={() => void handleSendMessage()}
|
|
type="button"
|
|
>
|
|
{sending ? 'Sending…' : 'Send message'}
|
|
<BaseIcon path={mdiArrowRight} size={18} />
|
|
</button>
|
|
<p className="text-[10px] leading-4 text-slate-400">
|
|
Saved automatically. Enter sends, Shift+Enter adds a new line.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-1 items-center justify-center bg-[#fcfcfd] px-5 py-12 md:px-7">
|
|
<div className="mx-auto flex max-w-4xl flex-col items-start rounded-[14px] border border-slate-200 bg-white p-8 md:p-10">
|
|
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-100 text-slate-600">
|
|
<BaseIcon path={mdiChatOutline} size={28} />
|
|
</div>
|
|
<p className="text-xs font-medium uppercase tracking-[0.32em] text-slate-400">
|
|
New chat
|
|
</p>
|
|
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-slate-900 md:text-[2.5rem]">
|
|
Start with one clear prompt.
|
|
</h2>
|
|
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-500 md:text-base">
|
|
Pick an agent, use one of the prompt starters, or write your own message below.
|
|
The first send creates the thread and drops it into the left sidebar automatically.
|
|
</p>
|
|
|
|
<div className="mt-8 grid gap-3 md:grid-cols-2">
|
|
{SUGGESTED_PROMPTS.map((prompt) => (
|
|
<button
|
|
className="rounded-[10px] border border-slate-200 bg-slate-50 px-5 py-4 text-left text-sm leading-6 text-slate-700"
|
|
key={prompt}
|
|
onClick={() => {
|
|
setComposer(prompt);
|
|
composerRef.current?.focus();
|
|
}}
|
|
type="button"
|
|
>
|
|
{prompt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-8 grid w-full gap-4 rounded-[14px] border border-slate-200 bg-slate-50 p-5 lg:grid-cols-[minmax(0,1fr)_260px]">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Selected agent</p>
|
|
<div className="mt-3 rounded-[10px] border border-slate-200 bg-white px-4 py-4">
|
|
<p className="text-lg font-medium text-slate-900">
|
|
{emptyStateAgent?.name || 'General Assistant'}
|
|
</p>
|
|
<p className="mt-2 text-sm leading-6 text-slate-500">
|
|
{emptyStateAgent?.description ||
|
|
'Choose a starting agent, then send the first message to create the chat.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 lg:w-[260px]">
|
|
<label className="rounded-[10px] border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">
|
|
<span className="mb-2 block text-[11px] uppercase tracking-[0.24em] text-slate-400">
|
|
Start with agent
|
|
</span>
|
|
<select
|
|
className="w-full bg-transparent text-sm text-slate-900 outline-none"
|
|
onChange={(event) => setSelectedAgentId(event.target.value)}
|
|
value={selectedAgentId}
|
|
>
|
|
{agents.map((agent) => (
|
|
<option className="bg-white text-slate-900" key={agent.id} value={agent.id}>
|
|
{agent.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
className="inline-flex items-center justify-center gap-2 rounded-[10px] bg-slate-900 px-5 py-4 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
|
|
disabled={!composer.trim() || sending || loadingBootstrap}
|
|
onClick={() => void handleSendMessage()}
|
|
type="button"
|
|
>
|
|
{sending ? 'Creating chat…' : 'Start conversation'}
|
|
<BaseIcon path={mdiArrowRight} size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 w-full rounded-[14px] border border-slate-200 bg-slate-50 p-4">
|
|
<p className="mb-3 text-xs uppercase tracking-[0.24em] text-slate-400">First message</p>
|
|
<textarea
|
|
className="min-h-[140px] w-full resize-none rounded-[10px] border border-slate-200 bg-white px-4 py-4 text-[15px] leading-7 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
|
|
onChange={(event) => setComposer(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void handleSendMessage();
|
|
}
|
|
}}
|
|
placeholder="Type your first message here to create a conversation…"
|
|
ref={composerRef}
|
|
value={composer}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|