From dcd11825218d1f37ab3b6a472c3659a2b8493e46 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 19 Apr 2026 01:22:34 +0200 Subject: [PATCH] fixed integration graph editor ai --- src/api/fileApi.ts | 2 + .../editor/Automation2FlowEditor.tsx | 39 ++- .../FlowEditor/editor/EditorChatPanel.tsx | 103 +++++++- .../editor/EditorWorkflowChatList.tsx | 127 ++++++++++ .../FolderTree/FolderTree.module.css | 52 ++-- src/components/FolderTree/FolderTree.tsx | 238 +++++++++++------- src/components/UnifiedDataBar/ChatsTab.tsx | 35 +-- src/components/UnifiedDataBar/FilesTab.tsx | 14 +- src/components/UnifiedDataBar/SourcesTab.tsx | 165 ++++++------ src/pages/basedata/FilesPage.tsx | 22 ++ src/styles/themes/light.css | 9 +- 11 files changed, 564 insertions(+), 242 deletions(-) create mode 100644 src/components/FlowEditor/editor/EditorWorkflowChatList.tsx diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 4fd3dfe..18dc47e 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -212,6 +212,8 @@ export interface FolderInfo { mandateId?: string; featureInstanceId?: string; createdAt?: number; + scope?: string; + neutralize?: boolean; } export async function fetchFolders( diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index 37a1fdb..1438279 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -48,6 +48,7 @@ import { Automation2DataFlowProvider } from '../context/Automation2DataFlowConte import { usePrompt } from '../../../hooks/usePrompt'; import { EditorChatPanel } from './EditorChatPanel'; import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel'; +import { EditorWorkflowChatList } from './EditorWorkflowChatList'; import { RunTracingPanel } from './RunTracingPanel'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; @@ -114,7 +115,8 @@ export const Automation2FlowEditor: React.FC = ({ in const [tracingRunId, setTracingRunId] = useState(null); const [tracingNodeStatuses, setTracingNodeStatuses] = useState>({}); const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes'); - const [udbTab, setUdbTab] = useState('chats'); + type LeftTab = UdbTab | 'ai'; + const [udbTab, setUdbTab] = useState('ai'); const udbContext: UdbContext = useMemo(() => ({ instanceId, @@ -649,19 +651,30 @@ export const Automation2FlowEditor: React.FC = ({ in {leftPanelOpen && (<>
- {(['chats', 'files', 'sources'] as const).map((tab) => ( + {(['ai', 'chats', 'files', 'sources'] as const).map((tab) => ( ))}
-
- {udbTab === 'chats' ? ( +
+ {/* + KI-Panel bleibt gemountet, damit der Chatverlauf beim Tab-Wechsel + (Chats / Dateien / Quellen) erhalten bleibt. Nur per CSS umblenden. + `key={currentWorkflowId}` setzt den Verlauf sauber zurΓΌck, wenn der + Nutzer einen anderen Workflow wΓ€hlt. + */} +
{ if (currentWorkflowId) handleLoad(currentWorkflowId); }} @@ -670,11 +683,21 @@ export const Automation2FlowEditor: React.FC = ({ in dataSources={dataSources} featureDataSources={featureDataSources} /> - ) : ( +
+ {udbTab === 'chats' && ( + + )} + {(udbTab === 'files' || udbTab === 'sources') && ( setUdbTab(tab as LeftTab)} hideTabs={['chats']} onFileSelect={onFileSelect} onSourcesChanged={onSourcesChanged} diff --git a/src/components/FlowEditor/editor/EditorChatPanel.tsx b/src/components/FlowEditor/editor/EditorChatPanel.tsx index 7afb10e..84064ff 100644 --- a/src/components/FlowEditor/editor/EditorChatPanel.tsx +++ b/src/components/FlowEditor/editor/EditorChatPanel.tsx @@ -7,11 +7,25 @@ * - Files: drag & drop from FolderTree onto input area, or click in UDB * - Data Sources: πŸ”— picker button next to input (toggle-select from active sources) */ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { startSseStream } from '../../../utils/sseClient'; import { ChatMessageList } from '../../Chat'; import type { ChatMessage } from '../../Chat'; import { getPageIcon } from '../../../config/pageRegistry'; +import api from '../../../api'; + +interface PersistedEditorChatMessage { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp?: number; + sequenceNr?: number; +} + +interface PersistedEditorChatResponse { + chatWorkflowId: string | null; + messages: PersistedEditorChatMessage[]; +} import { useLanguage } from '../../../providers/language/LanguageContext'; @@ -58,16 +72,55 @@ export const EditorChatPanel: React.FC = ({ instanceId, }) => { const { t } = useLanguage(); const [messages, setMessages] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); const [loading, setLoading] = useState(false); const [prompt, setPrompt] = useState(''); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); const [showSourcePicker, setShowSourcePicker] = useState(false); const [treeDropOver, setTreeDropOver] = useState(false); + const [stopping, setStopping] = useState(false); const abortRef = useRef<(() => void) | null>(null); + const assistantIdRef = useRef(null); const textareaRef = useRef(null); const pickerRef = useRef(null); + // Load persisted chat history from the backend whenever the workflow changes. + // The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is + // returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`. + // For an unsaved workflow (workflowId == null) we just clear the panel. + useEffect(() => { + if (!workflowId) { + setMessages([]); + return; + } + let cancelled = false; + const _loadHistory = async () => { + setHistoryLoading(true); + try { + const res = await api.get( + `/api/workflows/${instanceId}/${workflowId}/chat/messages`, + ); + if (cancelled) return; + const persisted = (res.data?.messages || []).map((m): ChatMessage => ({ + id: m.id || `persisted-${++_msgCounter}`, + role: m.role, + content: m.content, + timestamp: m.timestamp ? Math.round(Number(m.timestamp) * 1000) : Date.now(), + })); + setMessages(persisted); + } catch (err) { + if (cancelled) return; + console.warn('EditorChatPanel: failed to load chat history', err); + setMessages([]); + } finally { + if (!cancelled) setHistoryLoading(false); + } + }; + _loadHistory(); + return () => { cancelled = true; }; + }, [instanceId, workflowId]); + const _toggleDataSource = useCallback((dsId: string) => { setAttachedDataSourceIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId], @@ -85,9 +138,10 @@ export const EditorChatPanel: React.FC = ({ instanceId, if (!workflowId || loading || !trimmed) return; const fileIds = pendingFiles.map(f => f.fileId); + // Note: conversationHistory is no longer sent β€” the backend loads it + // server-side from the persisted ChatWorkflow (linkedWorkflowId). const body: Record = { message: trimmed, - conversationHistory: messages.map(m => ({ role: m.role, message: m.content })), userLanguage: navigator.language?.slice(0, 2) || 'de', }; if (fileIds.length > 0) body.fileIds = fileIds; @@ -106,11 +160,13 @@ export const EditorChatPanel: React.FC = ({ instanceId, setLoading(true); const assistantId = `asst-${++_msgCounter}`; + assistantIdRef.current = assistantId; let accumulated = ''; setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]); + const baseURL = api.defaults.baseURL || ''; const cleanup = startSseStream({ - url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`, + url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`, body, handlers: { onChunk: (event) => { @@ -142,17 +198,40 @@ export const EditorChatPanel: React.FC = ({ instanceId, } setLoading(false); }, - onStopped: () => setLoading(false), + onStopped: () => { + setMessages(prev => prev.map(m => m.id === assistantId + ? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Gestoppt.')}_` } + : m)); + setLoading(false); + setStopping(false); + }, }, onConnectionError: (err) => { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m)); setLoading(false); + setStopping(false); }, - onStreamEnd: () => setLoading(false), + onStreamEnd: () => { setLoading(false); setStopping(false); }, }); abortRef.current = cleanup; - }, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]); + }, [prompt, loading, workflowId, instanceId, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]); + + const _handleStop = useCallback(async () => { + if (!workflowId || stopping) return; + setStopping(true); + const assistantId = assistantIdRef.current; + if (assistantId) { + setMessages(prev => prev.map(m => m.id === assistantId + ? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Stoppen…')}_` } + : m)); + } + try { + await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`); + } catch { + } + abortRef.current?.(); + }, [workflowId, instanceId, stopping, t]); const _handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -188,8 +267,8 @@ export const EditorChatPanel: React.FC = ({ instanceId,
{/* Pending files (from UDB drag/click) */} @@ -391,10 +470,12 @@ export const EditorChatPanel: React.FC = ({ instanceId, )} {loading ? ( - + background: stopping ? '#9e9e9e' : '#f44336', color: '#fff', + cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12, + opacity: stopping ? 0.7 : 1, + }}>{stopping ? t('Stoppen…') : t('Stopp')} ) : ( +
+ +
+ {filtered.length === 0 ? ( +
+ {workflows.length === 0 + ? t('Noch keine Workflows. Klicken Sie auf β€ž+ Neu", um einen Workflow-Chat zu starten.') + : t('Keine Treffer.')} +
+ ) : ( + filtered.map((wf) => { + const isActive = wf.id === currentWorkflowId; + const ts = wf.lastStartedAt || wf.createdAt; + return ( +
onSelect(wf.id)} + style={{ + padding: '10px 12px', cursor: 'pointer', + borderBottom: '1px solid var(--border-color-soft, #f0f0f0)', + background: isActive ? 'rgba(242, 88, 67, 0.08)' : 'transparent', + borderLeft: isActive ? '3px solid var(--primary-color, #F25843)' : '3px solid transparent', + }} + onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = '#f7f7f7'; }} + onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }} + > +
+ + {wf.label || t('(unbenannt)')} + + {wf.isRunning && ( + + )} +
+
+ {typeof wf.runCount === 'number' && ( + {wf.runCount} {wf.runCount === 1 ? t('Lauf') : t('LΓ€ufe')} + )} + {ts ? Β· {_formatRelative(ts)} : null} +
+
+ ); + }) + )} +
+
+ ); +}; + +export default EditorWorkflowChatList; diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css index b13f1fe..d0db9f7 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -86,10 +86,21 @@ min-width: 0; } +/* Right zone: contains dynamic on-hover actions + always-visible stable trio. + * The stable trio (chat / scope / neutralize) sits at the right edge in a + * fixed slot order so icons never jump. Dynamic actions appear on hover + * to the left of the trio without displacing it. */ +.rightZone { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + flex-shrink: 0; +} + .actions { display: none; gap: 2px; - margin-left: auto; flex-shrink: 0; } @@ -97,6 +108,26 @@ display: flex; } +.stableActions { + display: flex; + gap: 2px; + flex-shrink: 0; + align-items: center; +} + +.iconSlot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 20px; + flex-shrink: 0; +} + +.iconSlot.placeholder { + visibility: hidden; +} + .actionBtn { background: none; border: none; @@ -148,25 +179,6 @@ flex-shrink: 0; } -.scopeIcons { - display: flex; - gap: 2px; - flex-shrink: 0; - align-items: center; -} - -.rightZone { - display: flex; - align-items: center; - gap: 4px; - margin-left: auto; - flex-shrink: 0; -} - -.rightZone .actions { - margin-left: 0; -} - .rootActions { display: flex; gap: 2px; diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 2e2194c..289b404 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -30,6 +30,7 @@ export interface FolderNode { isReadonly?: boolean; icon?: string; neutralize?: boolean; + scope?: string; } export interface FileNode { @@ -76,6 +77,7 @@ export interface FolderTreeProps { onDownloadFolder?: (folderId: string, folderName: string) => Promise; onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; + onFolderScopeChange?: (folderId: string, newScope: string) => void; onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; } @@ -186,6 +188,78 @@ interface SelectionCtx { onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; } +/* ── Stable trio (chat | scope | neutralize) ────────────────────────────── + * Always rendered in this order, always at the right edge of the row. + * Each slot has a fixed width so missing actions render an invisible + * placeholder β€” icons never jump position between rows. */ + +interface StableTrioProps { + scope?: string; + neutralize?: boolean; + scopeLabels: Record; + onChat?: () => void; + onScopeChange?: (newScope: string) => void; + onNeutralizeToggle?: (newValue: boolean) => void; + chatTitle: string; +} + +function _StableTrio({ + scope, neutralize, + scopeLabels, + onChat, onScopeChange, onNeutralizeToggle, + chatTitle, +}: StableTrioProps) { + const { t } = useLanguage(); + const _cycleScope = (current: string | undefined) => { + const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal'); + return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length]; + }; + + return ( + + {/* Slot 1: Chat */} + {onChat ? ( + + ) : ( + + )} + {/* Slot 2: Scope */} + {onScopeChange && scope != null ? ( + + ) : ( + + )} + {/* Slot 3: Neutralize */} + {onNeutralizeToggle ? ( + + ) : ( + + )} + + ); +} + /* ── File node (leaf) ─────────────────────────────────────────────────── */ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { @@ -265,12 +339,12 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { )} {!renaming && ( + {file.fileSize != null && ( + + {(file.fileSize / 1024).toFixed(0)}K + + )} - {sel.onSendToChat && ( - - )} {sel.onRenameFile && !multiSelected && ( - - - )} + <_StableTrio + scope={file.scope} + neutralize={file.neutralize} + scopeLabels={scopeLabels} + onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined} + onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined} + onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined} + chatTitle={t('In Chat senden')} + /> )}
@@ -360,6 +409,7 @@ interface TreeNodeProps { onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; onDownloadFolder?: (folderId: string, folderName: string) => Promise; + onFolderScopeChange?: (folderId: string, newScope: string) => void; onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; } @@ -368,9 +418,15 @@ function _TreeNode({ promptFolderName, onToggle, onSelect, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, - onDownloadFolder, onFolderNeutralizeToggle, + onDownloadFolder, onFolderScopeChange, onFolderNeutralizeToggle, }: TreeNodeProps) { const { t } = useLanguage(); + const scopeLabels = useMemo((): Record => ({ + personal: t('PersΓΆnlich'), + featureInstance: t('Instanz'), + mandate: t('Mandant'), + global: t('Global'), + }), [t]); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(node.name); const [dropOver, setDropOver] = useState(false); @@ -523,57 +579,53 @@ function _TreeNode({ {node.name} )} {!isProtected && ( - - {sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {isMultiSelected && sel.selectedItemIds.size > 1 ? ( - <> - {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( - - )} - {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( - - )} - - ) : !notEditable && onDeleteFolder && ( - - )} + + + {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {isMultiSelected && sel.selectedItemIds.size > 1 ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : !notEditable && onDeleteFolder && ( + + )} + + <_StableTrio + scope={node.scope} + neutralize={node.neutralize} + scopeLabels={scopeLabels} + onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined} + onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined} + onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined} + chatTitle={t('In Chat senden')} + /> )}
@@ -600,6 +652,7 @@ function _TreeNode({ onMoveFile={onMoveFile} onMoveFiles={onMoveFiles} onDownloadFolder={onDownloadFolder} + onFolderScopeChange={onFolderScopeChange} onFolderNeutralizeToggle={onFolderNeutralizeToggle} /> ))} @@ -620,7 +673,7 @@ export default function FolderTree({ expandedIds: externalExpandedIds, onToggleExpand, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, - onScopeChange, onNeutralizeToggle, onFolderNeutralizeToggle, onSendToChat, + onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat, }: FolderTreeProps) { const { t } = useLanguage(); @@ -848,6 +901,7 @@ export default function FolderTree({ onMoveFile={onMoveFile} onMoveFiles={onMoveFiles} onDownloadFolder={onDownloadFolder} + onFolderScopeChange={onFolderScopeChange} onFolderNeutralizeToggle={onFolderNeutralizeToggle} /> ))} diff --git a/src/components/UnifiedDataBar/ChatsTab.tsx b/src/components/UnifiedDataBar/ChatsTab.tsx index a9a1b23..86e474e 100644 --- a/src/components/UnifiedDataBar/ChatsTab.tsx +++ b/src/components/UnifiedDataBar/ChatsTab.tsx @@ -52,6 +52,22 @@ function _formatRelativeTime(dateStr?: string | number): string { 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 = ({ context, onSelectChat, onDragStart, @@ -113,13 +129,7 @@ const ChatsTab: React.FC = ({ context, } 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; - }), - ); + sorted.forEach(g => g.chats.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a))); setGroups(sorted); if (expandedGroups.size === 0 && sorted.length > 0) { @@ -218,16 +228,9 @@ const ChatsTab: React.FC = ({ context, .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; - }); + .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); @@ -270,7 +273,7 @@ const ChatsTab: React.FC = ({ context, ) : ( <> - {_formatRelativeTime(chat.updatedAt)} + {_formatRelativeTime(_lastTouchValue(chat))} = ({ context, onFileSelect, onSendToChat name: f.name, parentId: f.parentId ?? null, fileCount: f.fileCount ?? 0, - neutralize: (f as any).neutralize ?? false, + neutralize: f.neutralize ?? false, + scope: f.scope ?? 'personal', })); }, [folders]); @@ -178,6 +179,16 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat } }, [refreshFolders, refreshTreeFiles]); + const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => { + try { + await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope }); + await refreshFolders(); + await refreshTreeFiles(); + } catch (err) { + console.error('Failed to change folder scope:', err); + } + }, [refreshFolders, refreshTreeFiles]); + if (treeFilesLoading && treeFileNodes.length === 0) { return
{t('Dateien laden')}
; } @@ -268,6 +279,7 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat onDownloadFolder={handleDownloadFolder} onScopeChange={_onScopeChange} onNeutralizeToggle={_onNeutralizeToggle} + onFolderScopeChange={_onFolderScopeChange} onFolderNeutralizeToggle={_onFolderNeutralizeToggle} onSendToChat={onSendToChat} /> diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index aec1197..ad1f6d1 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -1097,12 +1097,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ {node.label}
- {/* Chat-Senden: always visible */} + {/* Dynamic action: Remove (only when DS exists) β€” placed LEFT of the + * stable trio so the trio always anchors at the right edge. */} + {ds && ( + + )} + + {/* ── Stable trio: chat | scope | neutralize (always in this order) ── */} - - {/* Scope: own DS β†’ cycle, no DS β†’ create DS then cycle */} - - {/* Neutralize: own DS β†’ toggle, no DS β†’ create DS then toggle */} - {/* Remove: only when DS exists */} - {ds && ( - - )} - {node.expanded && node.children && node.children.length > 0 && ( @@ -1366,7 +1363,18 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ {node.tableCount} {t('Tabellen')} - {/* Chat: always visible */} + {/* Dynamic Remove (left of stable trio) */} + {wildcardFds && ( + + )} + + {/* ── Stable trio: chat | scope | neutralize ── */} - - {/* Scope: own wildcard-FDS β†’ cycle, otherwise create then cycle */} - - {/* Neutralize: own wildcard-FDS β†’ toggle, otherwise create then toggle */} - {/* Remove: only when wildcard-FDS exists */} - {wildcardFds && ( - - )} - {node.expanded && node.tables && node.tables.length > 0 && ( @@ -1587,20 +1580,29 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ )} - {/* Chat: always visible */} + {/* Dynamic Remove (left of stable trio) */} + {fds && onRemoveFds && ( + + )} + + {/* ── Stable trio: chat | scope | neutralize ── */} - - {/* Scope: own FDS β†’ cycle, otherwise create then cycle */} - - {/* Neutralize: own FDS β†’ toggle, otherwise create then toggle */} - {/* Remove: only when FDS exists */} - {fds && onRemoveFds && ( - - )} - {/* Expandable field sub-nodes */} @@ -1732,26 +1721,30 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({ {fieldName} - {/* Chat: always visible */} + {/* ── Stable trio: chat | scope | neutralize ── */} - - {/* Neutralize: own FDS β†’ clickable, otherwise dimmed */} + + {_SCOPE_ICONS[inheritedScope || 'personal']} + {fds && onToggleNeutralizeField ? ( ) : ( {'\uD83D\uDD12'} )} - - {/* Scope: inherited indicator */} - - {_SCOPE_ICONS[inheritedScope || 'personal']} - ); }; @@ -1949,7 +1934,18 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ {record.displayLabel} - {/* Chat: always visible */} + {/* Dynamic Remove (left of stable trio) */} + {fds && onRemoveFds && ( + + )} + + {/* ── Stable trio: chat | scope | neutralize ── */} - - {/* Scope: own FDS β†’ clickable, otherwise dimmed */} {fds && onCycleScope ? ( ) : ( {_SCOPE_ICONS[inheritedScope || 'personal']} )} - - {/* Neutralize: own FDS β†’ clickable, otherwise dimmed */} {fds && onToggleNeutralize ? ( ) : ( {'\uD83D\uDD12'} )} - {/* Remove: only when FDS exists */} - {fds && onRemoveFds && ( - - )} - {record.expanded && ( diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index af1638d..79d6199 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -142,6 +142,24 @@ export const FilesPage: React.FC = () => { } }, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]); + const _handleFolderScopeChange = useCallback(async (folderId: string, newScope: string) => { + try { + await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope }); + await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]); + } catch (err) { + console.error('Failed to update folder scope:', err); + } + }, [refreshFolders, refreshTreeFiles, _tableRefetch]); + + const _handleFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => { + try { + await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue }); + await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]); + } catch (err) { + console.error('Failed to toggle folder neutralize:', err); + } + }, [refreshFolders, refreshTreeFiles, _tableRefetch]); + // ── Folder nodes for tree (real folders only) ──────────────────────── const folderNodes = useMemo(() => { return folders.map(f => ({ @@ -149,6 +167,8 @@ export const FilesPage: React.FC = () => { name: f.name, parentId: f.parentId ?? null, fileCount: f.fileCount ?? 0, + neutralize: f.neutralize ?? false, + scope: f.scope ?? 'personal', })); }, [folders]); @@ -426,6 +446,8 @@ export const FilesPage: React.FC = () => { onDownloadFolder={handleDownloadFolder} onScopeChange={_handleScopeChange} onNeutralizeToggle={_handleNeutralizeToggle} + onFolderScopeChange={_handleFolderScopeChange} + onFolderNeutralizeToggle={_handleFolderNeutralizeToggle} /> diff --git a/src/styles/themes/light.css b/src/styles/themes/light.css index 5ce307b..f4df202 100644 --- a/src/styles/themes/light.css +++ b/src/styles/themes/light.css @@ -52,7 +52,9 @@ --bg-primary: #ffffff; --bg-secondary: #F7FAFC; --bg-dark: #EDF2F7; - + /* Canvas surface for the GraphicalEditor flow editor (slightly off-white). */ + --canvas-bg: #FAFAFA; + /* Surface colors */ --surface-color: #F7FAFC; --surface-dark: #EDF2F7; @@ -126,7 +128,10 @@ --bg-primary: #1A202C; --bg-secondary: #2D3748; --bg-dark: #171923; - + /* Canvas surface for the GraphicalEditor flow editor β€” slightly darker than + --bg-primary so nodes and connection lines stay legible against it. */ + --canvas-bg: #131820; + /* Surface colors */ --surface-color: #2D3748; --surface-dark: #1A202C;