import React, { useState, useEffect, useCallback, useRef } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import styles from './ChatsTab.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; interface ChatItem { id: string; label: string; updatedAt?: string | number; lastMessageAt?: string | number; featureInstanceId?: string; featureCode?: string; status?: string; } interface ChatGroup { featureInstanceId: string; featureLabel: string; featureCode: string; chats: ChatItem[]; } type ChatFilter = 'active' | 'archived'; interface ChatsTabProps { context: UdbContext; onSelectChat?: (chatId: string, featureInstanceId: string) => void; onDragStart?: (chatId: string, event: React.DragEvent) => void; activeWorkflowId?: string; onCreateNew?: () => void; onRenameChat?: (chatId: string, newName: string) => void | Promise; onDeleteChat?: (chatId: string) => void | Promise; } function _formatRelativeTime(dateStr?: string | number): string { if (!dateStr) return ''; const d = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr); if (isNaN(d.getTime())) return ''; const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffMin = Math.floor(diffMs / 60_000); const diffH = Math.floor(diffMs / 3_600_000); const diffDays = Math.floor(diffMs / 86_400_000); if (diffMin < 1) return 'gerade eben'; if (diffMin < 60) return `${diffMin}m`; if (diffH < 24) return `${diffH}h`; if (diffDays === 1) return 'gestern'; if (diffDays < 7) return `vor ${diffDays}d`; return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart, activeWorkflowId, onCreateNew, onRenameChat, onDeleteChat, }) => { const { t } = useLanguage(); const [groups, setGroups] = useState([]); const [flatMode, setFlatMode] = useState(false); const [search, setSearch] = useState(''); const [filter, setFilter] = useState('active'); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [loading, setLoading] = useState(true); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const renameInputRef = useRef(null); const _loadChats = useCallback(async (serverSearch?: string) => { setLoading(true); try { const params: Record = { includeArchived: true }; if (serverSearch) params.search = serverSearch; const response = await api.get( `/api/workspace/${context.instanceId}/workflows`, { params }, ); const body = response.data ?? {}; const nested = body.data && typeof body.data === 'object' && !Array.isArray(body.data) ? (body.data as { workflows?: unknown }) : null; const workflowsRaw = body.workflows ?? nested?.workflows ?? (Array.isArray(body.data) ? body.data : null); const workflows = Array.isArray(workflowsRaw) ? workflowsRaw : []; const groupMap = new Map(); for (const wf of workflows) { const fiId = wf.featureInstanceId || context.instanceId; if (!groupMap.has(fiId)) { groupMap.set(fiId, { featureInstanceId: fiId, featureLabel: wf.featureLabel || wf.featureCode || fiId.slice(0, 8), featureCode: wf.featureCode || 'workspace', chats: [], }); } groupMap.get(fiId)!.chats.push({ id: wf.id, label: wf.label || wf.name || `${t('Chat')} ${wf.id.slice(0, 8)}`, updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt, lastMessageAt: wf.lastMessageAt, featureInstanceId: fiId, featureCode: wf.featureCode, status: wf.status || 'active', }); } const sorted = Array.from(groupMap.values()); sorted.forEach(g => g.chats.sort((a, b) => { const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime(); const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime(); return tb - ta; }), ); setGroups(sorted); if (expandedGroups.size === 0 && sorted.length > 0) { const currentGroup = sorted.find(g => g.featureInstanceId === context.instanceId); const sectionKey = currentGroup ? `section:${currentGroup.featureCode || 'workspace'}` : 'section:workspace'; setExpandedGroups(new Set([context.instanceId, sectionKey])); } } catch (err) { console.error('Failed to load chats:', err); } finally { setLoading(false); } }, [context.instanceId, t]); useEffect(() => { _loadChats(); }, [_loadChats]); useEffect(() => { const timer = setTimeout(() => { if (search.trim().length >= 2) { _loadChats(search.trim()); } else if (search.trim().length === 0) { _loadChats(); } }, 300); return () => clearTimeout(timer); }, [search, _loadChats]); useEffect(() => { if (activeWorkflowId) { _loadChats(); } }, [activeWorkflowId]); useEffect(() => { if (editingId && renameInputRef.current) { renameInputRef.current.focus(); renameInputRef.current.select(); } }, [editingId]); const _toggleGroup = (id: string) => { setExpandedGroups(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }; const _startEditing = (chat: ChatItem) => { if (!onRenameChat) return; setEditingId(chat.id); setEditName(chat.label); }; const _commitRename = async (chatId: string) => { const trimmed = editName.trim(); setEditingId(null); if (!trimmed || !onRenameChat) return; await onRenameChat(chatId, trimmed); _loadChats(); }; const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => { if (e.key === 'Enter') { e.preventDefault(); _commitRename(chatId); } else if (e.key === 'Escape') { setEditingId(null); } }; const _archiveChat = useCallback(async (chatId: string) => { try { await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' }); _loadChats(); } catch (err) { console.error('Failed to archive chat:', err); } }, [context.instanceId, _loadChats]); const _restoreChat = useCallback(async (chatId: string) => { try { await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' }); _loadChats(); } catch (err) { console.error('Failed to restore chat:', err); } }, [context.instanceId, _loadChats]); const _isArchived = (chat: ChatItem) => chat.status === 'archived'; const _applyFilter = (chats: ChatItem[]) => chats.filter(c => filter === 'archived' ? _isArchived(c) : !_isArchived(c)); const _filteredGroups = groups .map(g => ({ ...g, chats: _applyFilter(g.chats) })) .filter(g => g.chats.length > 0); const _toTs = (v?: string | number): number => typeof v === 'number' ? v : new Date(v || 0).getTime(); const _allChats = _filteredGroups .flatMap(g => g.chats) .sort((a, b) => { const ta = _toTs(a.lastMessageAt ?? a.updatedAt); const tb = _toTs(b.lastMessageAt ?? b.updatedAt); return tb - ta; }); const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0); const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0); const _renderChatItem = (chat: ChatItem, featureInstanceId: string) => { const isActive = activeWorkflowId === chat.id; const isEditing = editingId === chat.id; const archived = _isArchived(chat); const itemClassName = [ styles.chatItem, isActive ? styles.chatItemActive : '', archived ? styles.chatItemArchived : '', ].filter(Boolean).join(' '); return (
{ if (!isEditing) onSelectChat?.(chat.id, featureInstanceId); }} draggable={!!onDragStart && !isEditing} onDragStart={(e) => { e.dataTransfer.setData('application/chat-id', chat.id); e.dataTransfer.setData('text/plain', chat.label); onDragStart?.(chat.id, e); }} > {isEditing ? ( setEditName(e.target.value)} onBlur={() => _commitRename(chat.id)} onKeyDown={(e) => _handleRenameKeyDown(e, chat.id)} onClick={(e) => e.stopPropagation()} /> ) : ( <> {_formatRelativeTime(chat.updatedAt)} {chat.label} {onRenameChat && ( )} {archived ? ( ) : ( )} {onDeleteChat && ( )} )}
); }; const _featureCodeLabel = (code: string): string => { const labels: Record = { workspace: t('KI-Arbeitsbereich'), commcoach: t('CommCoach'), trustee: t('Trustee'), automation: t('Automation'), }; return labels[code] || code; }; if (loading) return
{t('Chats werden geladen…')}
; return (
setSearch(e.target.value)} /> {onCreateNew && ( )}
{flatMode ? (
{_allChats.map((chat) => _renderChatItem(chat, chat.featureInstanceId || context.instanceId), )}
) : (
{(() => { const byFeatureCode = new Map(); for (const g of _filteredGroups) { const code = g.featureCode || 'workspace'; if (!byFeatureCode.has(code)) byFeatureCode.set(code, []); byFeatureCode.get(code)!.push(g); } return Array.from(byFeatureCode.entries()).map(([code, instances]) => (
_toggleGroup(`section:${code}`)} > {expandedGroups.has(`section:${code}`) ? '\u25BC' : '\u25B6'} {_featureCodeLabel(code)} {instances.reduce((n, g) => n + g.chats.length, 0)}
{expandedGroups.has(`section:${code}`) && instances.map((group) => (
{instances.length > 1 && (
_toggleGroup(group.featureInstanceId)} > {expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'} {group.featureLabel} {group.chats.length}
)} {(instances.length === 1 || expandedGroups.has(group.featureInstanceId)) && (
{group.chats.map((chat) => _renderChatItem(chat, group.featureInstanceId), )}
)}
))}
)); })()}
)} {_allChats.length === 0 && (
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
)}
); }; export default ChatsTab;