From a7bfdddc0030a39c53b4ddc38c023350a37adaf7 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 16 Mar 2026 11:38:29 +0100 Subject: [PATCH] ai workspace ui fixes --- src/hooks/useFiles.ts | 5 +- src/pages/admin/Admin.module.css | 28 ++ src/pages/basedata/ConnectionsPage.tsx | 4 +- .../views/workspace/ConversationList.tsx | 245 +++++++++++++++-- src/pages/views/workspace/DataSourcePanel.tsx | 74 ++++-- src/pages/views/workspace/FileBrowser.tsx | 249 ++++++++++-------- src/pages/views/workspace/WorkspacePage.tsx | 69 ++++- .../views/workspace/WorkspaceSettings.tsx | 7 +- src/pages/views/workspace/useWorkspace.ts | 2 + 9 files changed, 510 insertions(+), 173 deletions(-) diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 9812b26..f680077 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -479,7 +479,7 @@ export function useFileOperations() { * - Removed workflowId from FileItem creation in interfaceComponentObjects.py * - Upload should now work correctly */ - const handleFileUpload = async (file: globalThis.File, workflowId?: string) => { + const handleFileUpload = async (file: globalThis.File, workflowId?: string, featureInstanceId?: string) => { setUploadError(null); setUploadingFile(true); @@ -500,6 +500,9 @@ export function useFileOperations() { if (workflowId) { formData.append('workflowId', workflowId); } + if (featureInstanceId) { + formData.append('featureInstanceId', featureInstanceId); + } // FormData is now correctly configured for backend diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css index ccb0b9b..4fcc8df 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -88,6 +88,34 @@ border-color: var(--text-secondary); } +.googleButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #4285f4; + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} + +.googleButton:hover { + background: #3367d6; +} + +.googleButton:active { + transform: scale(0.98); +} + +.googleButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* Filter Section Styles */ .filterSection { display: flex; diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 0661fdb..9cd0b7e 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -221,7 +221,7 @@ export const ConnectionsPage: React.FC = () => { {canCreate && ( <> + + + + + {/* View mode toggle */} +
+
- {conversations.length > 0 && ( + {/* Filter */} + {filtered.length > 3 && ( = ({ /> )} - {conversations.length === 0 && !loading && ( + {/* Empty state */} + {filtered.length === 0 && !loading && (
- No conversations yet. Send a message to start. + {viewMode === 'archived' + ? 'Keine archivierten Chats.' + : 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'}
)} - {_filtered(conversations, filterQuery).map(conv => { + {/* List */} + {paginated.map(conv => { const isActive = conv.id === activeWorkflowId; const isEditing = editingId === conv.id; return ( @@ -171,12 +265,18 @@ export const ConversationList: React.FC = ({ background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent', border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent', transition: 'background 0.15s', + position: 'relative', }} onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = '#f5f5f5'; + const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement; + if (actions) actions.style.opacity = '1'; }} onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; + const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement; + if (actions) actions.style.opacity = '0'; + if (confirmDeleteId === conv.id) setConfirmDeleteId(null); }} > {/* Name row */} @@ -208,23 +308,72 @@ export const ConversationList: React.FC = ({ minWidth: 0, }} onDoubleClick={(e) => { e.stopPropagation(); _startEditing(conv); }} - title="Double-click to rename" + title={conv.name} > {conv.name} )} + + {/* Action buttons (visible on hover) */} {!isEditing && ( - + + {conv.status === 'archived' ? ( + + ) : ( + + )} + {confirmDeleteId === conv.id ? ( + + + + + ) : ( + + )} + )} @@ -232,12 +381,15 @@ export const ConversationList: React.FC = ({
{conv.status === 'active' && ( - {'\u25CF'} active + {'\u25CF'} aktiv )} {conv.status === 'completed' && ( - {'\u25CF'} completed + {'\u25CF'} abgeschlossen )} - {conv.status !== 'active' && conv.status !== 'completed' && ( + {conv.status === 'archived' && ( + {'\u25CF'} archiviert + )} + {!['active', 'completed', 'archived'].includes(conv.status) && ( {conv.status} )} @@ -251,6 +403,45 @@ export const ConversationList: React.FC = ({
); })} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + {page + 1} / {totalPages} + +
+ )} ); }; + +const _actionBtnStyle: React.CSSProperties = { + background: 'none', + border: 'none', + cursor: 'pointer', + fontSize: 11, + color: '#999', + padding: '0 2px', +}; + +const _pageBtnStyle: React.CSSProperties = { + background: 'none', + border: '1px solid #ddd', + borderRadius: 4, + cursor: 'pointer', + padding: '2px 8px', + color: '#666', +}; diff --git a/src/pages/views/workspace/DataSourcePanel.tsx b/src/pages/views/workspace/DataSourcePanel.tsx index 4cea053..780e492 100644 --- a/src/pages/views/workspace/DataSourcePanel.tsx +++ b/src/pages/views/workspace/DataSourcePanel.tsx @@ -54,6 +54,33 @@ const _SERVICE_ICONS: Record = { files: '\uD83D\uDCC2', }; +/* ─── Source colors & icons ──────────────────────────────────────────── */ + +const _SOURCE_COLORS: Record = { + sharepointFolder: '#0078d4', + onedriveFolder: '#0078d4', + outlookFolder: '#0078d4', + googleDriveFolder: '#34a853', + gmailFolder: '#ea4335', + ftpFolder: '#795548', +}; + +function _getSourceColor(sourceType: string): string { + return _SOURCE_COLORS[sourceType] || '#1976d2'; +} + +function _getSourceIcon(sourceType: string): string { + const map: Record = { + sharepointFolder: '\uD83D\uDCC1', + onedriveFolder: '\u2601\uFE0F', + outlookFolder: '\uD83D\uDCE7', + googleDriveFolder: '\uD83D\uDCC2', + gmailFolder: '\uD83D\uDCE8', + ftpFolder: '\uD83D\uDD17', + }; + return map[sourceType] || '\uD83D\uDCC1'; +} + /* ─── Component ─────────────────────────────────────────────────────── */ export const DataSourcePanel: React.FC = ({ @@ -186,26 +213,33 @@ export const DataSourcePanel: React.FC = ({
Active Sources
- {dataSources.map(ds => ( -
- {'\u25CF'} - - {ds.label} - - -
- ))} + {dataSources.map(ds => { + const connColor = _getSourceColor(ds.sourceType); + const connNode = tree.find(n => n.connectionId === ds.connectionId); + const connLabel = connNode?.label || ds.connectionId; + const fullPath = `${connLabel} › ${ds.sourceType} › ${ds.path}`; + return ( +
+ {_getSourceIcon(ds.sourceType)} + + {ds.label} + + +
+ ); + })}
)} diff --git a/src/pages/views/workspace/FileBrowser.tsx b/src/pages/views/workspace/FileBrowser.tsx index 21e5f33..8008dc9 100644 --- a/src/pages/views/workspace/FileBrowser.tsx +++ b/src/pages/views/workspace/FileBrowser.tsx @@ -1,8 +1,13 @@ /** - * FileBrowser -- Folder + file browser panel with tags, search, and drag-and-drop upload. + * FileBrowser -- Tree-structured file browser. + * + * Level 1: Feature instance (group header, collapsible) + * Level 2: Files sorted alphabetically + * + * Supports search, drag-and-drop upload, and file selection. */ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useRef, useMemo } from 'react'; import api from '../../../api'; import type { WorkspaceFile, WorkspaceFolder } from './useWorkspace'; @@ -14,6 +19,12 @@ interface FileBrowserProps { onFileSelect?: (fileId: string) => void; } +interface _InstanceGroup { + instanceId: string; + label: string; + files: WorkspaceFile[]; +} + export const FileBrowser: React.FC = ({ instanceId, files, @@ -21,27 +32,44 @@ export const FileBrowser: React.FC = ({ onRefresh, onFileSelect, }) => { - const [currentFolderId, setCurrentFolderId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [uploading, setUploading] = useState(false); + const [collapsed, setCollapsed] = useState>({}); const fileInputRef = useRef(null); - const currentFolders = folders.filter(f => - currentFolderId ? f.parentId === currentFolderId : !f.parentId, - ); - const currentFiles = files.filter(f => { - const inFolder = currentFolderId ? f.folderId === currentFolderId : !f.folderId; - const matchesSearch = !searchQuery - || f.fileName.toLowerCase().includes(searchQuery.toLowerCase()) - || (f.tags || []).some(t => t.toLowerCase().includes(searchQuery.toLowerCase())); - return inFolder && matchesSearch; - }); + const _filteredFiles = useMemo(() => { + if (!searchQuery.trim()) return files; + const q = searchQuery.toLowerCase(); + return files.filter(f => + f.fileName.toLowerCase().includes(q) + || (f.tags || []).some(t => t.toLowerCase().includes(q)), + ); + }, [files, searchQuery]); - const _navigateUp = () => { - if (!currentFolderId) return; - const folder = folders.find(f => f.id === currentFolderId); - setCurrentFolderId(folder?.parentId || null); + const _groups = useMemo((): _InstanceGroup[] => { + const map: Record = {}; + for (const f of _filteredFiles) { + const key = f.featureInstanceId || '_workspace'; + if (!map[key]) { + map[key] = { + instanceId: key, + label: f.featureInstanceLabel || (key === '_workspace' ? 'Workspace' : key.slice(0, 8)), + files: [], + }; + } + map[key].files.push(f); + } + for (const g of Object.values(map)) { + g.files.sort((a, b) => a.fileName.localeCompare(b.fileName)); + } + const groups = Object.values(map); + groups.sort((a, b) => a.label.localeCompare(b.label)); + return groups; + }, [_filteredFiles]); + + const _toggleGroup = (key: string) => { + setCollapsed(prev => ({ ...prev, [key]: !prev[key] })); }; const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { @@ -51,9 +79,7 @@ export const FileBrowser: React.FC = ({ for (const file of Array.from(fileList)) { const formData = new FormData(); formData.append('file', file); - if (currentFolderId) { - formData.append('folderId', currentFolderId); - } + formData.append('featureInstanceId', instanceId); await api.post('/api/files/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); @@ -64,7 +90,7 @@ export const FileBrowser: React.FC = ({ } finally { setUploading(false); } - }, [instanceId, uploading, currentFolderId, onRefresh]); + }, [instanceId, uploading, onRefresh]); const _handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -101,23 +127,15 @@ export const FileBrowser: React.FC = ({ onDragLeave={_handleDragLeave} onDrop={_handleDrop} > - {/* Drag overlay */} {isDragOver && (
- Drop files to upload + Dateien hier ablegen
)} @@ -133,22 +151,16 @@ export const FileBrowser: React.FC = ({ > {uploading ? '...' : '+'} - + - + {/* Search */} setSearchQuery(e.target.value)} style={{ @@ -157,85 +169,90 @@ export const FileBrowser: React.FC = ({ }} /> - {/* Breadcrumb */} - {currentFolderId && ( -
- ← Back -
- )} - - {/* Folders */} - {currentFolders.map(folder => ( -
setCurrentFolderId(folder.id)} - style={{ - padding: '6px 8px', cursor: 'pointer', fontSize: 13, - display: 'flex', alignItems: 'center', gap: 6, - borderRadius: 4, - }} - onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} - onMouseLeave={e => (e.currentTarget.style.background = '')} - > - 📁 - {folder.name} -
- ))} - - {/* Files */} - {currentFiles.map(file => ( -
onFileSelect?.(file.id)} - style={{ - padding: '6px 8px', fontSize: 13, - display: 'flex', alignItems: 'center', gap: 6, - borderRadius: 4, - cursor: onFileSelect ? 'pointer' : 'default', - }} - onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} - onMouseLeave={e => (e.currentTarget.style.background = '')} - > - {_fileIcon(file.mimeType)} -
-
- {file.fileName} -
- {file.tags && file.tags.length > 0 && ( -
- {file.tags.map(tag => ( - - {tag} - - ))} -
- )} -
- - {(file.fileSize / 1024).toFixed(0)}K - -
- ))} - - {currentFiles.length === 0 && currentFolders.length === 0 && ( + {/* Tree */} + {_groups.length === 0 && (
- {searchQuery ? 'No files match your search' : 'No files. Drag & drop to upload.'} + {searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
)} + + {_groups.map(group => { + const isCollapsed = !!collapsed[group.instanceId]; + return ( +
+ {/* Group header */} +
_toggleGroup(group.instanceId)} + style={{ + display: 'flex', alignItems: 'center', gap: 6, + padding: '5px 6px', cursor: 'pointer', borderRadius: 4, + background: 'var(--bg-secondary, #f5f5f5)', + marginBottom: 2, + }} + onMouseEnter={e => (e.currentTarget.style.background = '#eee')} + onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)')} + > + + {isCollapsed ? '\u25B6' : '\u25BC'} + + {'\uD83D\uDCC1'} + + {group.label} + + {group.files.length} +
+ + {/* Files */} + {!isCollapsed && group.files.map(file => ( +
onFileSelect?.(file.id)} + style={{ + padding: '4px 8px 4px 28px', fontSize: 12, + display: 'flex', alignItems: 'center', gap: 6, + borderRadius: 4, + cursor: onFileSelect ? 'pointer' : 'default', + }} + onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} + onMouseLeave={e => (e.currentTarget.style.background = '')} + > + {_fileIcon(file.mimeType)} +
+
+ {file.fileName} +
+ {file.tags && file.tags.length > 0 && ( +
+ {file.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+ + {(file.fileSize / 1024).toFixed(0)}K + +
+ ))} +
+ ); + })} ); }; function _fileIcon(mime: string): string { - if (mime.startsWith('image/')) return '🖼️'; - if (mime.includes('pdf')) return '📕'; - if (mime.includes('word') || mime.includes('docx')) return '📘'; - if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '📊'; - if (mime.includes('presentation') || mime.includes('pptx')) return '📙'; - if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '📦'; - if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '📝'; - return '📄'; + if (!mime) return '\uD83D\uDCC4'; + if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; + if (mime.includes('pdf')) return '\uD83D\uDCD5'; + if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8'; + if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA'; + if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9'; + if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6'; + if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD'; + if (mime.startsWith('audio/')) return '\uD83C\uDFB5'; + if (mime.startsWith('video/')) return '\uD83C\uDFA5'; + return '\uD83D\uDCC4'; } diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx index 09b1e9e..fcaa23f 100644 --- a/src/pages/views/workspace/WorkspacePage.tsx +++ b/src/pages/views/workspace/WorkspacePage.tsx @@ -18,6 +18,39 @@ import { FileBrowser } from './FileBrowser'; import { DataSourcePanel } from './DataSourcePanel'; import { FilePreview } from './FilePreview'; import { ToolActivityLog } from './ToolActivityLog'; + +function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) { + const [width, setWidth] = useState(initialWidth); + const dragging = useRef(false); + const startX = useRef(0); + const startW = useRef(0); + + const _onMouseDown = useCallback((e: React.MouseEvent, direction: 1 | -1 = 1) => { + e.preventDefault(); + dragging.current = true; + startX.current = e.clientX; + startW.current = width; + + const _onMouseMove = (ev: MouseEvent) => { + if (!dragging.current) return; + const delta = (ev.clientX - startX.current) * direction; + setWidth(Math.max(minWidth, Math.min(maxWidth, startW.current + delta))); + }; + const _onMouseUp = () => { + dragging.current = false; + document.removeEventListener('mousemove', _onMouseMove); + document.removeEventListener('mouseup', _onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.addEventListener('mousemove', _onMouseMove); + document.addEventListener('mouseup', _onMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, [width, minWidth, maxWidth]); + + return { width, onMouseDown: _onMouseDown }; +} type LeftTab = 'conversations' | 'files' | 'datasources'; type RightTab = 'activity' | 'preview'; @@ -37,6 +70,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance const fileOps = useFileOperations(); const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false); + const _leftResize = _useResizable(280, 200, 450); + const _rightResize = _useResizable(320, 200, 500); const [leftTab, setLeftTab] = useState('conversations'); const [rightTab, setRightTab] = useState('activity'); const [selectedFileId, setSelectedFileId] = useState(null); @@ -47,7 +82,7 @@ export const WorkspacePage: React.FC = ({ persistentInstance const fileInputRef = useRef(null); const _uploadAndAttach = useCallback(async (file: File) => { - const result = await fileOps.handleFileUpload(file); + const result = await fileOps.handleFileUpload(file, undefined, instanceId); if (result.success && result.fileData) { const data = result.fileData.file || result.fileData; if (data?.id) { @@ -55,7 +90,7 @@ export const WorkspacePage: React.FC = ({ persistentInstance } workspace.refreshFiles(); } - }, [fileOps, workspace]); + }, [fileOps, workspace, instanceId]); const _handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -136,12 +171,13 @@ export const WorkspacePage: React.FC = ({ persistentInstance {/* Left sidebar */} {!leftCollapsed && ( )} + {/* Left resize handle */} + {!leftCollapsed && ( +
_leftResize.onMouseDown(e, 1)} + style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }} + onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + /> + )} + {leftCollapsed && (
@@ -239,15 +285,26 @@ export const WorkspacePage: React.FC = ({ persistentInstance /> + {/* Right resize handle */} + {!rightCollapsed && ( +
_rightResize.onMouseDown(e, -1)} + style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }} + onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + /> + )} + {/* Right sidebar */} {!rightCollapsed && (