ui-nyla/src/components/UnifiedDataBar/ChatsTab.tsx
ValueOn AG d579df1c92
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
panel ui
2026-06-11 16:43:53 +02:00

489 lines
16 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.

// Copyright (c) 2026 PowerOn AG
// All rights reserved.
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, label?: string) => void;
onDragStart?: (chatId: string, event: React.DragEvent) => void;
activeWorkflowId?: string;
chatListRefreshKey?: number;
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' });
}
/**
* Timestamp der letzten Aktivität eines Chats: `lastMessageAt` (Backend liefert
* den Zeitstempel der letzten Nachricht) bevorzugt, sonst Fallback auf
* `updatedAt` (Workflow-Lifecycle-Zeit). Wird sowohl für Anzeige als auch
* Sortierung verwendet, damit Liste und Label konsistent sind.
*/
function _lastTouchValue(chat: ChatItem): string | number | undefined {
return chat.lastMessageAt ?? chat.updatedAt;
}
function _lastTouchTs(chat: ChatItem): number {
const v = _lastTouchValue(chat);
if (v === undefined || v === null) return 0;
return typeof v === 'number' ? v : new Date(v).getTime();
}
const ChatsTab: React.FC<ChatsTabProps> = ({ context,
onSelectChat,
onDragStart,
activeWorkflowId,
chatListRefreshKey,
onRenameChat,
onDeleteChat,
}) => {
const { t } = useLanguage();
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 [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const groupsRef = useRef(groups);
groupsRef.current = groups;
const _loadChats = useCallback(async (serverSearch?: string) => {
try {
const params: Record<string, unknown> = { 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<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 || `${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) => _lastTouchTs(b) - _lastTouchTs(a)));
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 {
setHasLoadedOnce(true);
}
}, [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 (chatListRefreshKey) {
_loadChats();
}
}, [chatListRefreshKey, _loadChats]);
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;
const prev = groupsRef.current;
setGroups(gs => gs.map(g => ({
...g,
chats: g.chats.map(c => (c.id === chatId ? { ...c, label: trimmed } : c)),
})));
try {
await onRenameChat(chatId, trimmed);
_loadChats();
} catch (err) {
console.error('Failed to rename chat:', err);
setGroups(prev);
}
};
const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitRename(chatId);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
const _setChatStatus = useCallback((chatId: string, status: string) => {
setGroups(gs => gs.map(g => ({
...g,
chats: g.chats.map(c => (c.id === chatId ? { ...c, status } : c)),
})));
}, []);
const _removeChat = useCallback((chatId: string) => {
setGroups(gs => gs.map(g => ({
...g,
chats: g.chats.filter(c => c.id !== chatId),
})).filter(g => g.chats.length > 0));
}, []);
const _archiveChat = useCallback(async (chatId: string) => {
const prev = groupsRef.current;
_setChatStatus(chatId, 'archived');
try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
} catch (err) {
console.error('Failed to archive chat:', err);
setGroups(prev);
}
}, [context.instanceId, _setChatStatus]);
const _restoreChat = useCallback(async (chatId: string) => {
const prev = groupsRef.current;
_setChatStatus(chatId, 'active');
try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
} catch (err) {
console.error('Failed to restore chat:', err);
setGroups(prev);
}
}, [context.instanceId, _setChatStatus]);
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 _allChats = _filteredGroups
.flatMap(g => g.chats)
.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a));
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, chat.label);
}}
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(_lastTouchValue(chat))}
</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={t('Umbenennen')}
>
</button>
)}
{archived ? (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _restoreChat(chat.id); }}
title={t('Wiederherstellen')}
>
</button>
) : (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _archiveChat(chat.id); }}
title={t('Archivieren')}
>
📦
</button>
)}
{onDeleteChat && (
<button
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={async (e) => {
e.stopPropagation();
const prev = groupsRef.current;
_removeChat(chat.id);
try {
await onDeleteChat(chat.id);
} catch (err) {
console.error('Failed to delete chat:', err);
setGroups(prev);
}
}}
title={t('Löschen')}
>
🗑
</button>
)}
</span>
</>
)}
</div>
);
};
const _featureCodeLabel = (code: string): string => {
const labels: Record<string, string> = {
workspace: t('KI-Arbeitsbereich'),
commcoach: t('CommCoach'),
trustee: t('Trustee'),
automation: t('Automation'),
};
return labels[code] || code;
};
return (
<div className={styles.chatsTab}>
<div className={styles.toolbar}>
<input
className={styles.search}
type="text"
placeholder={t('Suchen')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)}
title={flatMode ? t('Baumansicht') : t('Listenansicht')}
>
{flatMode ? '\uD83C\uDF33' : '\uD83D\uDCCB'}
</button>
</div>
<div className={styles.filterTabs}>
<button
className={`${styles.filterTab} ${filter === 'active' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('active')}
>
{t('Aktiv')} ({_activeCount})
</button>
<button
className={`${styles.filterTab} ${filter === 'archived' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('archived')}
>
{t('Archiv')} ({_archivedCount})
</button>
</div>
{flatMode ? (
<div className={styles.flatList}>
{_allChats.map((chat) =>
_renderChatItem(chat, chat.featureInstanceId || context.instanceId),
)}
</div>
) : (
<div className={styles.tree}>
{(() => {
const byFeatureCode = new Map<string, ChatGroup[]>();
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]) => (
<div key={code} className={styles.treeSection}>
<div
className={styles.treeSectionHeader}
onClick={() => _toggleGroup(`section:${code}`)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(`section:${code}`) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeSectionLabel}>
{_featureCodeLabel(code)}
</span>
<span className={styles.treeGroupCount}>
{instances.reduce((n, g) => n + g.chats.length, 0)}
</span>
</div>
{expandedGroups.has(`section:${code}`) && instances.map((group) => (
<div key={group.featureInstanceId} className={styles.treeGroup}>
{instances.length > 1 && (
<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>
)}
{(instances.length === 1 || expandedGroups.has(group.featureInstanceId)) && (
<div className={styles.treeChildren}>
{group.chats.map((chat) =>
_renderChatItem(chat, group.featureInstanceId),
)}
</div>
)}
</div>
))}
</div>
));
})()}
</div>
)}
{hasLoadedOnce && _allChats.length === 0 && (
<div className={styles.emptyState}>
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
</div>
)}
</div>
);
};
export default ChatsTab;