fixed integration graph editor ai

This commit is contained in:
ValueOn AG 2026-04-19 01:22:34 +02:00
parent 13f4574098
commit dcd1182521
11 changed files with 564 additions and 242 deletions

View file

@ -212,6 +212,8 @@ export interface FolderInfo {
mandateId?: string;
featureInstanceId?: string;
createdAt?: number;
scope?: string;
neutralize?: boolean;
}
export async function fetchFolders(

View file

@ -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<Automation2FlowEditorProps> = ({ in
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
type LeftTab = UdbTab | 'ai';
const [udbTab, setUdbTab] = useState<LeftTab>('ai');
const udbContext: UdbContext = useMemo(() => ({
instanceId,
@ -649,19 +651,30 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
{leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
<div className={styles.rightTabBar}>
{(['chats', 'files', 'sources'] as const).map((tab) => (
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
<button
key={tab}
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
onClick={() => 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]}
</button>
))}
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
{udbTab === 'chats' ? (
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
{/*
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.
*/}
<div style={{
display: udbTab === 'ai' ? 'flex' : 'none',
flexDirection: 'column',
height: '100%',
}}>
<EditorChatPanel
key={currentWorkflowId || '__noWorkflow__'}
instanceId={instanceId}
workflowId={currentWorkflowId}
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
@ -670,11 +683,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
dataSources={dataSources}
featureDataSources={featureDataSources}
/>
) : (
</div>
{udbTab === 'chats' && (
<EditorWorkflowChatList
workflows={workflows}
currentWorkflowId={currentWorkflowId}
onSelect={handleWorkflowSelect}
onNew={handleNew}
t={t}
/>
)}
{(udbTab === 'files' || udbTab === 'sources') && (
<UnifiedDataBar
context={udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
activeTab={udbTab as UdbTab}
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
hideTabs={['chats']}
onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged}

View file

@ -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<EditorChatPanelProps> = ({ instanceId,
}) => {
const { t } = useLanguage();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [prompt, setPrompt] = useState('');
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const [treeDropOver, setTreeDropOver] = useState(false);
const [stopping, setStopping] = useState(false);
const abortRef = useRef<(() => void) | null>(null);
const assistantIdRef = useRef<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(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<PersistedEditorChatResponse>(
`/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<EditorChatPanelProps> = ({ 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<string, unknown> = {
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<EditorChatPanelProps> = ({ 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<EditorChatPanelProps> = ({ 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<EditorChatPanelProps> = ({ instanceId,
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
<ChatMessageList
messages={messages}
isProcessing={loading}
emptyMessage={t('Beschreiben Sie, was Sie tun möchten')}
isProcessing={loading || historyLoading}
emptyMessage={historyLoading ? t('Lade Verlauf…') : t('Beschreiben Sie, was Sie tun möchten')}
/>
{/* Pending files (from UDB drag/click) */}
@ -391,10 +470,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
)}
{loading ? (
<button onClick={() => abortRef.current?.()} style={{
<button onClick={_handleStop} disabled={stopping} title={stopping ? t('Stoppen…') : t('Anfrage stoppen')} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12,
}}>{t('Stopp')}</button>
background: stopping ? '#9e9e9e' : '#f44336', color: '#fff',
cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12,
opacity: stopping ? 0.7 : 1,
}}>{stopping ? t('Stoppen…') : t('Stopp')}</button>
) : (
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',

View file

@ -0,0 +1,127 @@
/**
* EditorWorkflowChatList
*
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
* as one editor chat session. Lists workflows already loaded by the parent
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
* GraphicalEditor data instead of the workspace endpoint.
*/
import React, { useMemo, useState } from 'react';
import type { Automation2Workflow } from '../../../api/workflowApi';
interface EditorWorkflowChatListProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onSelect: (workflowId: string | null) => 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<EditorWorkflowChatListProps> = ({
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-primary, #fff)' }}>
<div style={{ padding: '8px 10px', display: 'flex', gap: 6, borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<input
type="search"
value={search}
onChange={(e) => 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',
}}
/>
<button
onClick={onNew}
title={t('Neuer Workflow')}
style={{
padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border-color, #ddd)',
background: 'var(--secondary-bg, #f5f5f5)', cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>+ {t('Neu')}</button>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{filtered.length === 0 ? (
<div style={{ padding: 16, fontSize: 12, color: '#999', textAlign: 'center' }}>
{workflows.length === 0
? t('Noch keine Workflows. Klicken Sie auf „+ Neu", um einen Workflow-Chat zu starten.')
: t('Keine Treffer.')}
</div>
) : (
filtered.map((wf) => {
const isActive = wf.id === currentWorkflowId;
const ts = wf.lastStartedAt || wf.createdAt;
return (
<div
key={wf.id}
onClick={() => 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'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: '#333', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{wf.label || t('(unbenannt)')}
</span>
{wf.isRunning && (
<span title={t('läuft')} style={{
width: 8, height: 8, borderRadius: '50%', background: '#4caf50', flexShrink: 0,
}} />
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4, fontSize: 11, color: '#999' }}>
{typeof wf.runCount === 'number' && (
<span>{wf.runCount} {wf.runCount === 1 ? t('Lauf') : t('Läufe')}</span>
)}
{ts ? <span>· {_formatRelative(ts)}</span> : null}
</div>
</div>
);
})
)}
</div>
</div>
);
};
export default EditorWorkflowChatList;

View file

@ -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;

View file

@ -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<void>;
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<string, string>;
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 (
<span className={styles.stableActions}>
{/* Slot 1: Chat */}
{onChat ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onChat(); }}
title={chatTitle}
style={{ fontSize: 12 }}
>
{'\u{1F4AC}'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\u{1F4AC}'}</span>
)}
{/* Slot 2: Scope */}
{onScopeChange && scope != null ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onScopeChange(_cycleScope(scope)); }}
title={`${t('Scope')}: ${scopeLabels[scope] || scope} (${t('klicken zum Wechseln')})`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[scope] || _SCOPE_ICONS.personal}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{_SCOPE_ICONS.personal}</span>
)}
{/* Slot 3: Neutralize */}
{onNeutralizeToggle ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { 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'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\uD83D\uDD12'}</span>
)}
</span>
);
}
/* ── File node (leaf) ─────────────────────────────────────────────────── */
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
@ -265,12 +339,12 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
)}
{!renaming && (
<span className={styles.rightZone}>
{file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
<span className={styles.actions}>
{sel.onSendToChat && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
{'\u{1F4AC}'}
</button>
)}
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen />
@ -299,40 +373,15 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
)
)}
</span>
{file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
{file.scope != null && (
<span className={styles.scopeIcons}>
<button
className={styles.actionBtn}
onClick={(e) => {
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'}
</button>
<button
className={styles.actionBtn}
onClick={(e) => {
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'}
</button>
</span>
)}
<_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')}
/>
</span>
)}
</div>
@ -360,6 +409,7 @@ interface TreeNodeProps {
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
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<string, string> => ({
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({
<span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span>
)}
{!isProtected && (
<span className={styles.actions}>
{sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
{'\u{1F4AC}'}
</button>
)}
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
<FaDownload />
</button>
)}
{onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button
className={styles.actionBtn}
onClick={(e) => { 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'}
</button>
)}
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
<FaPlus />
</button>
)}
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen />
</button>
)}
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : !notEditable && onDeleteFolder && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
<FaTrash />
</button>
)}
<span className={styles.rightZone}>
<span className={styles.actions}>
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
<FaDownload />
</button>
)}
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
<FaPlus />
</button>
)}
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen />
</button>
)}
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : !notEditable && onDeleteFolder && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
<FaTrash />
</button>
)}
</span>
<_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')}
/>
</span>
)}
</div>
@ -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}
/>
))}

View file

@ -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<ChatsTabProps> = ({ context,
onSelectChat,
onDragStart,
@ -113,13 +129,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ 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<ChatsTabProps> = ({ 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<ChatsTabProps> = ({ context,
) : (
<>
<span className={styles.chatDate}>
{_formatRelativeTime(chat.updatedAt)}
{_formatRelativeTime(_lastTouchValue(chat))}
</span>
<span
className={styles.chatLabel}

View file

@ -47,7 +47,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ 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<FilesTabProps> = ({ 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 <div className={styles.loading}>{t('Dateien laden')}</div>;
}
@ -268,6 +279,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle}
onFolderScopeChange={_onFolderScopeChange}
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
onSendToChat={onSendToChat}
/>

View file

@ -1097,12 +1097,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
{node.label}
</span>
{/* 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 && (
<button
onClick={e => { 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'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize (always in this order) ── */}
<button
onClick={e => { 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}'}
</button>
{/* Scope: own DS → cycle, no DS → create DS then cycle */}
<button
onClick={async (e) => {
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']}
</button>
{/* Neutralize: own DS → toggle, no DS → create DS then toggle */}
<button
onClick={async (e) => {
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'}
</button>
{/* Remove: only when DS exists */}
{ds && (
<button
onClick={e => { e.stopPropagation(); onRemoveDs(ds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
</div>
{node.expanded && node.children && node.children.length > 0 && (
@ -1366,7 +1363,18 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
{node.tableCount} {t('Tabellen')}
</span>
{/* Chat: always visible */}
{/* Dynamic Remove (left of stable trio) */}
{wildcardFds && (
<button
onClick={e => { 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'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button
onClick={(e) => {
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}'}
</button>
{/* Scope: own wildcard-FDS → cycle, otherwise create then cycle */}
<button
onClick={async (e) => {
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']}
</button>
{/* Neutralize: own wildcard-FDS → toggle, otherwise create then toggle */}
<button
onClick={async (e) => {
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'}
</button>
{/* Remove: only when wildcard-FDS exists */}
{wildcardFds && (
<button
onClick={e => { e.stopPropagation(); onRemoveFds(wildcardFds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
</div>
{node.expanded && node.tables && node.tables.length > 0 && (
@ -1587,20 +1580,29 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
)}
</span>
{/* Chat: always visible */}
{/* Dynamic Remove (left of stable trio) */}
{fds && onRemoveFds && (
<button
onClick={e => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button
onClick={e => { 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: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}}
title={t('In Chat senden')}
>
{'\u{1F4AC}'}
</button>
{/* Scope: own FDS → cycle, otherwise create then cycle */}
<button
onClick={async (e) => {
e.stopPropagation();
@ -1610,13 +1612,11 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
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: fds ? 1 : 0.35 }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds ? 1 : 0.35 }}
title={fds ? `${t('Bereich')}: ${fds.scope}` : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[fds?.scope || effectiveScope || 'personal']}
</button>
{/* Neutralize: own FDS → toggle, otherwise create then toggle */}
<button
onClick={async (e) => {
e.stopPropagation();
@ -1628,7 +1628,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
}}
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: (fds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
}}
title={(fds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
@ -1636,17 +1636,6 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
{'\uD83D\uDD12'}
</button>
{/* Remove: only when FDS exists */}
{fds && onRemoveFds && (
<button
onClick={e => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
</div>
{/* Expandable field sub-nodes */}
@ -1732,26 +1721,30 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
{fieldName}
</span>
{/* Chat: always visible */}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 11, padding: '0 2px', flexShrink: 0, lineHeight: 1,
fontSize: 11, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}}
title={t('Feld in Chat senden')}
>
{'\u{1F4AC}'}
</button>
{/* Neutralize: own FDS → clickable, otherwise dimmed */}
<span
style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
>
{_SCOPE_ICONS[inheritedScope || 'personal']}
</span>
{fds && onToggleNeutralizeField ? (
<button
onClick={e => { e.stopPropagation(); onToggleNeutralizeField(fds, fieldName); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 11, padding: '0 2px', lineHeight: 1,
fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: isNeutralized ? 1 : 0.35,
}}
title={isNeutralized ? t('Feld-Neutralisierung an') : t('Feld-Neutralisierung aus')}
@ -1760,20 +1753,12 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
</button>
) : (
<span
style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, opacity: isNeutralized ? 0.5 : 0.15 }}
style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: isNeutralized ? 0.5 : 0.15, display: 'inline-block' }}
title={isNeutralized ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</span>
)}
{/* Scope: inherited indicator */}
<span
style={{ fontSize: 10, padding: '0 2px', lineHeight: 1, opacity: 0.25 }}
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
>
{_SCOPE_ICONS[inheritedScope || 'personal']}
</span>
</div>
);
};
@ -1949,7 +1934,18 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
{record.displayLabel}
</span>
{/* Chat: always visible */}
{/* Dynamic Remove (left of stable trio) */}
{fds && onRemoveFds && (
<button
onClick={(e) => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button
onClick={(e) => {
e.stopPropagation();
@ -1962,61 +1958,46 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
}}
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: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}}
title={t('In Chat senden')}
>
{'\u{1F4AC}'}
</button>
{/* Scope: own FDS → clickable, otherwise dimmed */}
{fds && onCycleScope ? (
<button
onClick={(e) => { e.stopPropagation(); onCycleScope(fds); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1 }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center' }}
title={`${t('Bereich')}: ${fds.scope}`}
>
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
</button>
) : (
<span
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: 0.25 }}
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
>
{_SCOPE_ICONS[inheritedScope || 'personal']}
</span>
)}
{/* Neutralize: own FDS → clickable, otherwise dimmed */}
{fds && onToggleNeutralize ? (
<button
onClick={(e) => { e.stopPropagation(); onToggleNeutralize(fds); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: fds.neutralize ? 1 : 0.35 }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds.neutralize ? 1 : 0.35 }}
title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
) : (
<span
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: (inheritedNeutralize ?? false) ? 0.5 : 0.15 }}
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (inheritedNeutralize ?? false) ? 0.5 : 0.15, display: 'inline-block' }}
title={(inheritedNeutralize ?? false) ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</span>
)}
{/* Remove: only when FDS exists */}
{fds && onRemoveFds && (
<button
onClick={(e) => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
</div>
{record.expanded && (

View file

@ -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}
/>
</div>

View file

@ -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;