- {(['chats', 'files', 'sources'] as const).map((tab) => (
+ {(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
setUdbTab(tab)}
>
- {{ chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
+ {{ ai: t('KI'), chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[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 ? (
- abortRef.current?.()} style={{
+ {t('Stopp')}
+ background: stopping ? '#9e9e9e' : '#f44336', color: '#fff',
+ cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12,
+ opacity: stopping ? 0.7 : 1,
+ }}>{stopping ? t('Stoppen…') : t('Stopp')}
) : (
void;
+ onNew: () => void;
+ t: (key: string) => string;
+}
+
+function _formatRelative(ts?: number): string {
+ if (!ts) return '';
+ const date = new Date(ts * 1000);
+ if (isNaN(date.getTime())) return '';
+ const diffMs = Date.now() - date.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 date.toLocaleDateString([], { month: 'short', day: 'numeric' });
+}
+
+export const EditorWorkflowChatList: React.FC = ({
+ workflows,
+ currentWorkflowId,
+ onSelect,
+ onNew,
+ t,
+}) => {
+ const [search, setSearch] = useState('');
+
+ const filtered = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ const list = q
+ ? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
+ : [...workflows];
+ list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
+ return list;
+ }, [workflows, search]);
+
+ return (
+
+
+ setSearch(e.target.value)}
+ placeholder={t('Workflow suchen…')}
+ style={{
+ flex: 1, padding: '6px 8px', borderRadius: 6,
+ border: '1px solid var(--border-color, #ddd)', fontSize: 12, outline: 'none',
+ }}
+ />
+ + {t('Neu')}
+
+
+
+ {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 ? (
+ { e.stopPropagation(); onChat(); }}
+ title={chatTitle}
+ style={{ fontSize: 12 }}
+ >
+ {'\u{1F4AC}'}
+
+ ) : (
+ {'\u{1F4AC}'}
+ )}
+ {/* Slot 2: Scope */}
+ {onScopeChange && scope != null ? (
+ { e.stopPropagation(); onScopeChange(_cycleScope(scope)); }}
+ title={`${t('Scope')}: ${scopeLabels[scope] || scope} (${t('klicken zum Wechseln')})`}
+ style={{ fontSize: 14 }}
+ >
+ {_SCOPE_ICONS[scope] || _SCOPE_ICONS.personal}
+
+ ) : (
+ {_SCOPE_ICONS.personal}
+ )}
+ {/* Slot 3: Neutralize */}
+ {onNeutralizeToggle ? (
+ { e.stopPropagation(); onNeutralizeToggle(!neutralize); }}
+ title={neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
+ style={{ fontSize: 14, opacity: neutralize ? 1 : 0.4 }}
+ >
+ {'\uD83D\uDD12'}
+
+ ) : (
+ {'\uD83D\uDD12'}
+ )}
+
+ );
+}
+
/* ── 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 && (
- { e.stopPropagation(); sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
- {'\u{1F4AC}'}
-
- )}
{sel.onRenameFile && !multiSelected && (
{ e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
@@ -299,40 +373,15 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
)
)}
- {file.fileSize != null && (
-
- {(file.fileSize / 1024).toFixed(0)}K
-
- )}
- {file.scope != null && (
-
- {
- e.stopPropagation();
- if (!sel.onScopeChange) return;
- const idx = _SCOPE_CYCLE.indexOf(file.scope!);
- const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
- sel.onScopeChange(file.id, next);
- }}
- title={`${t('Scope')}: ${scopeLabels[file.scope!] || file.scope} (${t('klicken zum Wechseln')})`}
- style={{ fontSize: 14 }}
- >
- {_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
-
- {
- e.stopPropagation();
- sel.onNeutralizeToggle?.(file.id, !file.neutralize);
- }}
- title={file.neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
- style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
- >
- {'\uD83D\uDD12'}
-
-
- )}
+ <_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) && (
- { e.stopPropagation(); sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
- {'\u{1F4AC}'}
-
- )}
- {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
- { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
-
-
- )}
- {onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
- { e.stopPropagation(); onFolderNeutralizeToggle(node.id, !node.neutralize); }}
- title={node.neutralize ? t('Ordner-Neutralisierung aktiv, klicken zum Deaktivieren') : t('Ordner-Neutralisierung aus, klicken zum Aktivieren')}
- style={{ fontSize: 14, opacity: node.neutralize ? 1 : 0.4 }}
- >
- {'\uD83D\uDD12'}
-
- )}
- {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
-
-
-
- )}
- {!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
- { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
-
-
- )}
- {isMultiSelected && sel.selectedItemIds.size > 1 ? (
- <>
- {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
-
-
- {sel.selectedFolderIds.length}
-
- )}
- {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
-
-
- {sel.selectedFileIds.length}
-
- )}
- >
- ) : !notEditable && onDeleteFolder && (
-
-
-
- )}
+
+
+ {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
+ { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
+
+
+ )}
+ {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
+
+
+
+ )}
+ {!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
+ { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
+
+
+ )}
+ {isMultiSelected && sel.selectedItemIds.size > 1 ? (
+ <>
+ {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
+
+
+ {sel.selectedFolderIds.length}
+
+ )}
+ {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
+
+
+ {sel.selectedFileIds.length}
+
+ )}
+ >
+ ) : !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 && (
+ { e.stopPropagation(); onRemoveDs(ds.id); }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
+ title={t('Entfernen')}
+ >
+ {'\u2715'}
+
+ )}
+
+ {/* ── Stable trio: chat | scope | neutralize (always in this order) ── */}
{ e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
- fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1,
+ fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: ds ? 0.7 : (hovered ? 0.5 : 0.25),
color: 'var(--primary-color, #F25843)',
}}
@@ -1110,8 +1122,6 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
>
{'\u{1F4AC}'}
-
- {/* Scope: own DS → cycle, no DS → create DS then cycle */}
{
e.stopPropagation();
@@ -1121,13 +1131,11 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
}
}}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: ds ? 1 : 0.35 }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: ds ? 1 : 0.35 }}
title={ds ? scopeCycleTitle(ds.scope) : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[ds?.scope || effectiveScope || 'personal']}
-
- {/* Neutralize: own DS → toggle, no DS → create DS then toggle */}
{
e.stopPropagation();
@@ -1139,7 +1147,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
- fontSize: 12, padding: '0 2px', lineHeight: 1,
+ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (ds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
}}
title={(ds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
@@ -1147,17 +1155,6 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
{'\uD83D\uDD12'}
- {/* Remove: only when DS exists */}
- {ds && (
- { e.stopPropagation(); onRemoveDs(ds.id); }}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
- title={t('Entfernen')}
- >
- {'\u2715'}
-
- )}
-
{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 && (
+
{ e.stopPropagation(); onRemoveFds(wildcardFds.id); }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
+ title={t('Entfernen')}
+ >
+ {'\u2715'}
+
+ )}
+
+ {/* ── Stable trio: chat | scope | neutralize ── */}
{
e.stopPropagation();
@@ -1379,15 +1387,13 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
- fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1,
+ fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}}
title={t('Alle Tabellen in Chat senden')}
>
{'\u{1F4AC}'}
-
- {/* Scope: own wildcard-FDS → cycle, otherwise create then cycle */}
{
e.stopPropagation();
@@ -1397,13 +1403,11 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope('personal') }); } catch {}
}
}}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: wildcardFds ? 1 : 0.35 }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : t('Scope setzen')}
>
{_SCOPE_ICONS[wildcardFds?.scope || 'personal']}
-
- {/* Neutralize: own wildcard-FDS → toggle, otherwise create then toggle */}
{
e.stopPropagation();
@@ -1413,23 +1417,12 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
}
}}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: wildcardFds ? (wildcardFds.neutralize ? 1 : 0.35) : 0.35 }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? (wildcardFds.neutralize ? 1 : 0.35) : 0.35 }}
title={wildcardFds?.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
- {/* Remove: only when wildcard-FDS exists */}
- {wildcardFds && (
-
{ e.stopPropagation(); onRemoveFds(wildcardFds.id); }}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
- title={t('Entfernen')}
- >
- {'\u2715'}
-
- )}
-
{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 && (
+