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