import React, { useState, useEffect, useCallback, useRef } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import styles from './ChatsTab.module.css'; interface ChatItem { id: string; label: string; updatedAt?: 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 [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 () => { setLoading(true); try { const response = await api.get( `/api/workspace/${context.instanceId}/workflows`, { params: { includeArchived: true } }, ); 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 || `Chat ${wf.id.slice(0, 8)}`, updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt, 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) { setExpandedGroups(new Set([context.instanceId])); } } catch (err) { console.error('Failed to load chats:', err); } finally { setLoading(false); } }, [context.instanceId]); useEffect(() => { _loadChats(); }, [_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 => { let chats = _applyFilter(g.chats); if (search) { chats = chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase())); } return { ...g, chats }; }) .filter(g => g.chats.length > 0); const _allChats = _filteredGroups .flatMap(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; }); 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 && ( )} )}
); }; if (loading) return
Lade Chats...
; return (
setSearch(e.target.value)} /> {onCreateNew && ( )}
{flatMode ? (
{_allChats.map((chat) => _renderChatItem(chat, chat.featureInstanceId || context.instanceId), )}
) : (
{_filteredGroups.map((group) => (
_toggleGroup(group.featureInstanceId)} > {expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'} {group.featureLabel} {group.chats.length}
{expandedGroups.has(group.featureInstanceId) && (
{group.chats.map((chat) => _renderChatItem(chat, group.featureInstanceId), )}
)}
))}
)} {_allChats.length === 0 && (
{filter === 'archived' ? 'Keine archivierten Chats.' : 'Keine aktiven Chats.'}
)}
); }; export default ChatsTab;