392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
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;
|