godcrm/components/TelegramChatLayout.tsx
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

628 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* TelegramChatLayout Component
* ADR-024: Telegram-like Chat Architecture
*
* Main container that combines:
* - ChatListView (left panel)
* - ChatConversationView (right panel)
* - Settings modal
* - Row binding modal
*
* Responsive: mobile shows one panel at a time
*/
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
X,
Settings,
Link2,
Users,
Edit3,
Trash2,
Bot,
Loader2
} from 'lucide-react';
import { cn } from '@/shared/utils/cn';
import { apiClient } from '@/shared/utils/apiClient';
import { useCurrentSpace } from '@/features/spaces/store/spacesStore';
import { useAuthStore } from '@/features/auth/store/authStore';
import { useAIChat } from '../context/AIChatContext';
import { useConversationMessages, type ChatActivityState } from '../hooks/useConversationMessages';
import { ChatListView, ChatPreview } from './ChatListView';
import { ChatConversationView, ChatMessageItem, ChatInfo } from './ChatConversationView';
import { ParticipantSelector, Participant } from './ParticipantSelector';
import { NewChatDialog, SelectedParticipant } from './NewChatDialog';
import { RowBinding, BoundRow } from './RowBinding';
import { MentionUser } from './MentionInput';
export interface TelegramChatLayoutProps {
className?: string;
}
export function TelegramChatLayout({ className }: TelegramChatLayoutProps) {
const {
isOpen,
closeChat,
currentAgent,
agents,
messages,
isLoading,
isStreaming,
selectAgent,
sendMessage,
clearMessages,
loadAgents,
conversations,
currentConversationId,
loadConversations,
selectConversation,
createNewConversation,
deleteConversation,
isLoadingConversations,
agentMode,
setAgentMode,
hasMoreAIMessages,
isFetchingOlderAIMessages,
fetchOlderAIMessages,
processingAgentName,
isAgentProcessing,
} = useAIChat();
const currentSpace = useCurrentSpace();
const authUser = useAuthStore(s => s.user);
const queryClient = useQueryClient();
// ADR-103: Adaptive polling state — track last user activity to transition active -> idle
// Starts at 1.5s when agent is processing, 3s when user recently sent a message,
// slows to 8s after 30s of inactivity.
const lastActivityRef = useRef<number>(Date.now());
const [isRecentlyActive, setIsRecentlyActive] = useState(false);
// Mark activity on conversation selection or message send
const markActivity = useCallback(() => {
lastActivityRef.current = Date.now();
setIsRecentlyActive(true);
}, []);
// Timer to transition from 'active' to 'idle' after 30s of inactivity
useEffect(() => {
if (!isRecentlyActive) return;
const timer = setInterval(() => {
const elapsed = Date.now() - lastActivityRef.current;
if (elapsed >= 30_000) {
setIsRecentlyActive(false);
}
}, 5_000); // Check every 5s
return () => clearInterval(timer);
}, [isRecentlyActive]);
// Mark activity when conversation changes (user selected a chat)
useEffect(() => {
if (currentConversationId) {
markActivity();
}
}, [currentConversationId, markActivity]);
// Compute adaptive polling state:
// - agent_processing (1.5s): backend agent is actively working
// - active (3s): user recently interacted (< 30s ago)
// - idle (8s): no recent activity
const chatActivityState: ChatActivityState = useMemo(() => {
if (isAgentProcessing || isStreaming) return 'agent_processing';
if (isRecentlyActive) return 'active';
return 'idle';
}, [isAgentProcessing, isStreaming, isRecentlyActive]);
// ADR-103: Use useConversationMessages for real-time polling (replaces stale AIChatContext.messages)
const {
messages: polledMessages,
isLoading: isPolledLoading,
isProcessing: isPolledProcessing,
processingAgentName: polledProcessingAgentName,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useConversationMessages(currentConversationId, {
adaptivePolling: true,
chatActivityState,
currentUserId: authUser?.id ? Number(authUser.id) : undefined,
});
// Use polled messages when available, fallback to context messages (for optimistic updates)
const effectiveMessages = polledMessages.length > 0 ? polledMessages : messages;
// Default agent selection (mirrors AIChatPanel.tsx logic)
const [defaultAgentId, setDefaultAgentId] = useState<number | null>(null);
const defaultAgentAppliedRef = useRef(false);
// Step 1: Read default agent from space settings
useEffect(() => {
if (currentSpace?.settings && typeof currentSpace.settings === 'object') {
const spaceSettings = currentSpace.settings as Record<string, unknown>;
if (spaceSettings.default_agent_id) {
setDefaultAgentId(Number(spaceSettings.default_agent_id));
} else {
setDefaultAgentId(null);
}
} else {
setDefaultAgentId(null);
}
// Reset applied flag when space changes so default re-applies
defaultAgentAppliedRef.current = false;
}, [currentSpace?.id, currentSpace?.settings]);
// Step 2: Auto-select default agent when chat opens
// Note: loadAgents() may auto-select the FIRST agent before this effect runs,
// so we can't rely on !currentAgent. Instead we use a ref to apply once per session.
useEffect(() => {
if (isOpen && defaultAgentId && agents.length > 0 && !defaultAgentAppliedRef.current) {
const defaultAgent = agents.find(a => a.id === defaultAgentId);
if (defaultAgent) {
defaultAgentAppliedRef.current = true;
// Only switch if no agent selected OR the wrong agent was auto-selected
if (!currentAgent || currentAgent.id !== defaultAgentId) {
selectAgent(defaultAgent);
}
}
}
}, [isOpen, defaultAgentId, agents, currentAgent, selectAgent]);
// UI State
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [showBindingModal, setShowBindingModal] = useState(false);
const [showNewChatModal, setShowNewChatModal] = useState(false);
// Chat state
const [chatTitle, setChatTitle] = useState('');
const [participants, setParticipants] = useState<Participant[]>([]);
const [boundRows, setBoundRows] = useState<BoundRow[]>([]);
// Pending sub-agents selected from NewChatDialog (cleared after first message sent)
const [pendingSubAgentIds, setPendingSubAgentIds] = useState<number[]>([]);
// Convert conversations to ChatPreview format
const chatPreviews: ChatPreview[] = useMemo(() => {
return conversations.map(conv => ({
id: conv.id,
title: conv.title || 'Новый чат',
type: 'agent' as const,
icon: agents.find(a => a.id === conv.agent_id)?.icon,
lastMessage: conv.lastMessage ? {
content: conv.lastMessage,
sender: 'AI',
time: conv.updatedAt ? new Date(conv.updatedAt) : new Date(),
isRead: true,
isOwn: false
} : undefined,
unreadCount: 0,
isPinned: false,
isMuted: false,
participantsCount: 2
}));
}, [conversations, agents]);
// Current chat info
// ADR-103: status reflects both SSE streaming AND backend polling processing state
const isAgentTyping = isStreaming || isPolledProcessing || isAgentProcessing;
const currentChatInfo: ChatInfo | null = useMemo(() => {
if (!currentConversationId) return null;
const conv = conversations.find(c => c.id === currentConversationId);
if (!conv) return null;
const agent = agents.find(a => a.id === conv.agent_id);
return {
id: conv.id,
title: conv.title || agent?.name || 'Чат',
type: 'agent',
icon: agent?.icon,
status: isAgentTyping ? 'typing' : 'online',
agentModel: agent?.model
};
}, [currentConversationId, conversations, agents, isAgentTyping]);
// Convert messages to ChatMessageItem format (preserving contentType for agent step grouping)
// ADR-103: Uses effectiveMessages (from useConversationMessages polling) instead of stale context messages
const chatMessages: ChatMessageItem[] = useMemo(() => {
return effectiveMessages.map((msg, index) => {
// Determine sender info from backend data or fallback to currentAgent
const isAgentMessage = msg.role === 'assistant' || msg.role === 'tool';
const isUserMessage = msg.role === 'user';
let sender: ChatMessageItem['sender'] | undefined;
if (msg.sender_id && msg.sender_name) {
// Backend provided sender info via users JOIN — use it for all messages
sender = {
id: msg.sender_id,
name: msg.sender_name,
avatar: msg.sender_avatar || undefined,
type: (msg.sender_user_type === 'agent' || msg.senderType === 'agent') ? 'agent' : 'user',
};
} else if (isAgentMessage && currentAgent) {
// Fallback for agent messages without sender_id
sender = {
id: currentAgent.id,
name: msg.agentName || currentAgent.name,
avatar: undefined,
type: 'agent' as const,
};
} else if (isUserMessage && msg.sender_id) {
// User message with sender_id but no name (shouldn't happen after backend fix)
sender = {
id: msg.sender_id,
name: msg.sender_name || 'User',
avatar: msg.sender_avatar || undefined,
type: 'user' as const,
};
}
return {
id: msg.id || `msg-${index}`,
content: msg.content,
role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
sender,
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
isRead: true,
attachments: msg.attachments?.map(att => ({
id: att.id,
name: att.name,
type: att.type,
url: att.url,
size: att.size
})),
// Preserve agent step metadata for turn grouping (ADR-092)
contentType: msg.contentType,
toolResults: msg.toolResults?.map(tr => ({
tool: tr.tool,
args: tr.args,
result: tr.result
})),
iterations: msg.iterations,
agentName: msg.agentName || msg.sender_name || currentAgent?.name,
is_deleted: msg.is_deleted,
};
});
}, [effectiveMessages, currentAgent]);
// Build mention users
// ADR-069: @ shows humans + bots, / shows agents
const mentionUsers: MentionUser[] = useMemo(() => {
return agents.map(agent => ({
id: agent.id,
name: agent.name,
icon: agent.icon,
type: 'agent' as const
}));
}, [agents]);
const mentionAgents: MentionUser[] = useMemo(() => {
return agents.map(agent => ({
id: agent.id,
name: agent.name,
icon: agent.icon,
type: 'agent' as const
}));
}, [agents]);
// Load conversations on mount
useEffect(() => {
if (isOpen) {
loadConversations();
loadAgents();
}
}, [isOpen, loadConversations, loadAgents]);
// Update mobile view when selecting chat
useEffect(() => {
if (currentConversationId) {
setMobileView('chat');
}
}, [currentConversationId]);
// Handlers
const handleSelectChat = async (chatId: number) => {
await selectConversation(chatId);
};
const handleCreateChat = () => {
setShowNewChatModal(true);
};
// ADR-024: Unified New Chat — agents are just participants
const handleStartChat = useCallback((selectedParticipants: SelectedParticipant[]) => {
const agentParticipants = selectedParticipants.filter(p => p.type === 'agent');
// Map selected agent participants back to AIAgent objects (by name match)
const selectedAgents = agentParticipants
.map(p => agents.find(a => a.name.toLowerCase() === p.name.toLowerCase()))
.filter((a): a is (typeof agents)[0] => a !== undefined);
// Select first agent as primary conversation partner
if (selectedAgents.length > 0) {
selectAgent(selectedAgents[0]);
}
// Store all selected agent IDs for multi-agent support
const subAgentIds = selectedAgents.map(a => a.id);
setPendingSubAgentIds(subAgentIds);
// Clear conversation state (conversation created on first message)
createNewConversation();
setShowNewChatModal(false);
}, [agents, selectAgent, createNewConversation]);
const handleSendMessage = async (content: string, attachments?: File[], mentions?: MentionUser[]) => {
// ADR-103: Mark activity so polling speeds up (active -> 3s, then agent_processing -> 1.5s)
markActivity();
// ADR-024: Convert MentionUser[] to format expected by sendMessage
const formattedMentions = mentions?.map(m => ({
id: m.id,
name: m.name,
type: m.type
}));
// Pass pending sub-agent IDs (from NewChatDialog selection) on first message
const subAgentIds = pendingSubAgentIds.length > 0 ? pendingSubAgentIds : undefined;
await sendMessage(content, attachments, undefined, formattedMentions, undefined, undefined, subAgentIds);
// Clear pending sub-agents after first message (conversation is now created)
if (pendingSubAgentIds.length > 0) {
setPendingSubAgentIds([]);
}
};
const handleBack = () => {
setMobileView('list');
};
const handleDeleteChat = (chatId: number) => {
deleteConversation(chatId);
};
if (!isOpen) return null;
return (
<div className={cn(
'fixed inset-y-0 right-0 z-40 flex bg-[var(--bg-primary)] border-l border-[var(--border-primary)] shadow-xl overscroll-contain',
'w-full sm:w-[420px] md:w-[800px] lg:w-[900px]',
className
)} style={{ overscrollBehavior: 'contain', touchAction: 'pan-y' }}>
{/* Left Panel: Chat List */}
<div className={cn(
'flex-shrink-0 w-full sm:w-[320px] border-r border-[var(--border-primary)] relative',
mobileView === 'chat' && 'hidden sm:block'
)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
<h2 className="font-semibold text-[var(--text-primary)]">Чаты</h2>
<button
onClick={closeChat}
className="p-1.5 rounded-lg text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
>
<X className="w-5 h-5" />
</button>
</div>
<ChatListView
chats={chatPreviews}
selectedChatId={currentConversationId}
onSelectChat={handleSelectChat}
onCreateChat={handleCreateChat}
onDeleteChat={handleDeleteChat}
isLoading={isLoadingConversations}
/>
</div>
{/* Right Panel: Active Chat */}
<div className={cn(
'flex-1 min-w-0',
mobileView === 'list' && 'hidden sm:block'
)}>
<ChatConversationView
chat={currentChatInfo}
messages={chatMessages}
isLoading={isPolledLoading || isLoading}
isTyping={isStreaming || isPolledProcessing}
typingAgentName={polledProcessingAgentName || processingAgentName}
onSendMessage={handleSendMessage}
onBack={handleBack}
onOpenSettings={() => setShowSettingsModal(true)}
mentionUsers={mentionUsers}
mentionAgents={mentionAgents}
currentUserId={authUser?.id ? Number(authUser.id) : undefined}
hasMoreMessages={hasNextPage || hasMoreAIMessages}
onLoadMoreMessages={hasNextPage ? (async () => { await fetchNextPage(); }) : fetchOlderAIMessages}
isLoadingMore={isFetchingNextPage || isFetchingOlderAIMessages}
/>
</div>
{/* New Chat Dialog — ADR-024: unified participant picker (agents + humans) */}
<NewChatDialog
isOpen={showNewChatModal}
agents={agents}
spaceId={currentSpace?.id}
onStartChat={handleStartChat}
onClose={() => setShowNewChatModal(false)}
/>
{/* Settings Modal */}
{showSettingsModal && currentChatInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--bg-primary)] rounded-xl shadow-xl w-full max-w-md mx-4 overflow-hidden max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-primary)] flex-shrink-0">
<h3 className="font-semibold text-[var(--text-primary)]">Настройки чата</h3>
<button
onClick={() => setShowSettingsModal(false)}
className="p-1 rounded text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Chat Avatar & Title */}
<div className="flex flex-col items-center">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center text-white text-3xl mb-3">
{currentChatInfo.icon || <Bot className="w-10 h-10" />}
</div>
<input
type="text"
value={chatTitle || currentChatInfo.title}
onChange={(e) => setChatTitle(e.target.value)}
className="text-center text-lg font-semibold bg-transparent border-b border-transparent hover:border-[var(--border-primary)] focus:border-[var(--color-primary-500)] focus:outline-none px-2 py-1 text-[var(--text-primary)]"
placeholder="Название чата"
/>
<span className="text-xs text-[var(--text-tertiary)] mt-1">
{currentChatInfo.agentModel || currentChatInfo.type}
</span>
</div>
{/* Bindings Section */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-[var(--text-primary)] flex items-center gap-2">
<Link2 className="w-4 h-4" />
Привязки к записям
</label>
<button
onClick={() => setShowBindingModal(true)}
className="text-xs text-[var(--color-primary-500)] hover:underline"
>
Добавить
</button>
</div>
{boundRows.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)] italic py-2">
Нет привязок. Привяжите чат к задаче, проекту или любой записи.
</p>
) : (
<div className="space-y-2">
{boundRows.map((row, idx) => (
<div
key={`${row.table_id}-${row.row_id}-${idx}`}
className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-tertiary)] rounded-lg"
>
<Link2 className="w-4 h-4 text-[var(--text-tertiary)]" />
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)] truncate">
{row.row_title || `#${row.row_id}`}
</div>
<div className="text-xs text-[var(--text-tertiary)]">
{row.table_name}
</div>
</div>
<button
onClick={() => {
setBoundRows(prev => prev.filter(
r => !(r.table_id === row.table_id && r.row_id === row.row_id)
));
}}
className="p-1 rounded text-[var(--text-tertiary)] hover:text-red-500"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
{/* Participants Section */}
<div>
<label className="text-sm font-medium text-[var(--text-primary)] flex items-center gap-2 mb-2">
<Users className="w-4 h-4" />
Участники
</label>
<ParticipantSelector
participants={participants}
onSelect={(p) => {
if (!participants.some(sp => sp.type === p.type && sp.id === p.id)) {
setParticipants([...participants, p]);
}
}}
onMultiSelect={setParticipants}
multiSelect={true}
placeholder="Добавить участника..."
/>
</div>
{/* Danger Zone */}
<div className="pt-4 border-t border-[var(--border-primary)]">
<button
onClick={() => {
if (currentConversationId) {
deleteConversation(currentConversationId);
setShowSettingsModal(false);
}
}}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
Удалить чат
</button>
</div>
</div>
</div>
</div>
)}
{/* Row Binding Modal */}
{showBindingModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--bg-primary)] rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-primary)] flex-shrink-0">
<h3 className="font-semibold text-[var(--text-primary)]">Привязать к записи</h3>
<button
onClick={() => setShowBindingModal(false)}
className="p-1 rounded text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<RowBinding
spaceId={currentSpace?.id}
boundRows={boundRows}
maxBindings={10}
compact={false}
onBind={(tableId, rowId, meta) => {
setBoundRows(prev => [...prev, {
table_id: tableId,
row_id: rowId,
table_name: meta?.tableName,
row_title: meta?.rowTitle
}]);
}}
onUnbind={(tableId, rowId) => {
setBoundRows(prev => prev.filter(
r => !(r.table_id === tableId && r.row_id === rowId)
));
}}
/>
</div>
<div className="flex justify-end gap-2 px-4 py-3 border-t border-[var(--border-primary)] flex-shrink-0">
<button
onClick={() => setShowBindingModal(false)}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg"
>
Отмена
</button>
<button
onClick={() => setShowBindingModal(false)}
className="px-4 py-2 text-sm bg-[var(--color-primary-500)] text-white rounded-lg hover:bg-[var(--color-primary-600)]"
>
Готово
</button>
</div>
</div>
</div>
)}
</div>
);
}