frontend_nyla/src/components/UnifiedDataBar/ChatsTab.tsx
2026-03-28 21:46:54 +01:00

392 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void>;
onDeleteChat?: (chatId: string) => void | Promise<void>;
}
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<ChatsTabProps> = ({
context,
onSelectChat,
onDragStart,
activeWorkflowId,
onCreateNew,
onRenameChat,
onDeleteChat,
}) => {
const [groups, setGroups] = useState<ChatGroup[]>([]);
const [flatMode, setFlatMode] = useState(false);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ChatFilter>('active');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const renameInputRef = useRef<HTMLInputElement>(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<string, ChatGroup>();
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 (
<div
key={chat.id}
className={itemClassName}
onClick={() => {
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 ? (
<input
ref={renameInputRef}
className={styles.renameInput}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={() => _commitRename(chat.id)}
onKeyDown={(e) => _handleRenameKeyDown(e, chat.id)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<>
<span className={styles.chatDate}>
{_formatRelativeTime(chat.updatedAt)}
</span>
<span
className={styles.chatLabel}
title={chat.label}
>
{chat.label}
</span>
<span className={styles.chatActions}>
{onRenameChat && (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _startEditing(chat); }}
title="Umbenennen"
>
</button>
)}
{archived ? (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _restoreChat(chat.id); }}
title="Wiederherstellen"
>
</button>
) : (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _archiveChat(chat.id); }}
title="Archivieren"
>
📦
</button>
)}
{onDeleteChat && (
<button
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
title="Löschen"
>
🗑
</button>
)}
</span>
</>
)}
</div>
);
};
if (loading) return <div className={styles.loading}>Lade Chats...</div>;
return (
<div className={styles.chatsTab}>
<div className={styles.toolbar}>
<input
className={styles.search}
type="text"
placeholder="Suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{onCreateNew && (
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title="Neuer Chat">
+
</button>
)}
<button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)}
title={flatMode ? 'Baumansicht' : 'Listenansicht'}
>
{flatMode ? '\uD83C\uDF33' : '\uD83D\uDCCB'}
</button>
</div>
<div className={styles.filterTabs}>
<button
className={`${styles.filterTab} ${filter === 'active' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('active')}
>
Aktiv ({_activeCount})
</button>
<button
className={`${styles.filterTab} ${filter === 'archived' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('archived')}
>
Archiv ({_archivedCount})
</button>
</div>
{flatMode ? (
<div className={styles.flatList}>
{_allChats.map((chat) =>
_renderChatItem(chat, chat.featureInstanceId || context.instanceId),
)}
</div>
) : (
<div className={styles.tree}>
{_filteredGroups.map((group) => (
<div key={group.featureInstanceId} className={styles.treeGroup}>
<div
className={`${styles.treeGroupHeader} ${
group.featureInstanceId === context.instanceId ? styles.treeGroupCurrent : ''
}`}
onClick={() => _toggleGroup(group.featureInstanceId)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeGroupLabel}>{group.featureLabel}</span>
<span className={styles.treeGroupCount}>{group.chats.length}</span>
</div>
{expandedGroups.has(group.featureInstanceId) && (
<div className={styles.treeChildren}>
{group.chats.map((chat) =>
_renderChatItem(chat, group.featureInstanceId),
)}
</div>
)}
</div>
))}
</div>
)}
{_allChats.length === 0 && (
<div className={styles.emptyState}>
{filter === 'archived' ? 'Keine archivierten Chats.' : 'Keine aktiven Chats.'}
</div>
)}
</div>
);
};
export default ChatsTab;