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 (
); } 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 (
{!isUser && (
{avatarLabel}
)}
{isUser ? currentUserName : 'Assistant'} {statusLabel && ( {statusLabel} )} {displayTime && ( {displayTime} )}
{message.pending ? ( ) : isStreaming ? (
{streamingText}
) : ( )}
{isUser && (
{avatarLabel.slice(0, 1)}
)}
); } type ConversationRowProps = { conversation: ConversationSummary; isActive: boolean; onSelect: (id: string) => void; }; function ConversationRow({ conversation, isActive, onSelect }: ConversationRowProps) { return ( ); } export default function WorkspaceShell() { const router = useRouter(); const composerRef = useRef(null); const scrollContainerRef = useRef(null); const didBootstrapRef = useRef(false); const { currentUser } = useAppSelector((state) => state.auth); const [agents, setAgents] = useState([]); const [conversations, setConversations] = useState([]); const [activeConversation, setActiveConversation] = useState(null); const [composer, setComposer] = useState(''); const [selectedAgentId, setSelectedAgentId] = useState(''); const [notice, setNotice] = useState(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([]); const [streamingMessageId, setStreamingMessageId] = useState(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 (
{isSidebarOpen && ( Settings {canAccessAdmin && ( Backoffice )}
{notice && (
{notice.message}
)} {activeConversation ? ( <>
{activeConversation.status === 'archived' ? 'Archived' : 'Active'} {activeConversation.agent?.name || 'General assistant'} {activeConversationMessageCount} messages
{isEditingTitle ? (
setDraftTitle(event.target.value)} placeholder="Give this conversation a title" value={draftTitle} />
) : (

{activeConversation.title}

)}

{activeConversation.summary || activeConversation.agent?.description || 'Keep the thread moving.'}

{loadingConversation ? (
) : visibleMessages.length ? (
{visibleMessages.map((message) => ( ))}
) : (

Keep this thread moving.

Ask a follow-up, switch the agent, or keep refining the work without opening a new chat.

)}

Composer

{activeConversation?.status === 'archived' ? 'Sending a message will reopen this archived conversation.' : 'Use Enter to send and Shift+Enter for a new line.'}

{activeConversation?.agent?.name || emptyStateAgent?.name || 'General Assistant'}