40015-vm/frontend/src/WorkspaceShell.tsx
2026-05-15 19:57:43 +00:00

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>
);
}