/** * WorkspacePage -- Unified AI Workspace * * 3-column layout: * Left sidebar: ConversationList, FileBrowser, DataSourcePanel * Center: ChatStream + WorkspaceInput * Right sidebar: FilePreview, ToolActivityLog */ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useFileOperations } from '../../../hooks/useFiles'; import { useWorkspace } from './useWorkspace'; import { ChatStream } from './ChatStream'; import { WorkspaceInput } from './WorkspaceInput'; import { ConversationList } from './ConversationList'; 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'; interface PendingFile { fileId: string; fileName: string; itemType?: 'file' | 'folder'; } interface WorkspacePageProps { persistentInstanceId?: string; } export const WorkspacePage: React.FC = ({ persistentInstanceId }) => { const { instance } = useCurrentInstance(); const instanceId = persistentInstanceId || instance?.id || ''; const workspace = useWorkspace(instanceId); const fileOps = useFileOperations(); const navigate = useNavigate(); const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{ mandateId: string; featureCode: string; instanceId: string; }>(); 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); const [pendingFiles, setPendingFiles] = useState([]); const [selectedProviders, setSelectedProviders] = useState([]); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' ? window.innerWidth <= 1024 : false, ); const [mobileLeftOpen, setMobileLeftOpen] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false); useEffect(() => { const _handleResize = () => { setIsMobile(window.innerWidth <= 1024); }; _handleResize(); window.addEventListener('resize', _handleResize); return () => window.removeEventListener('resize', _handleResize); }, []); useEffect(() => { if (!isMobile) { setMobileLeftOpen(false); setMobileRightOpen(false); } }, [isMobile]); const _uploadAndAttach = useCallback(async (file: File) => { const result = await fileOps.handleFileUpload(file, undefined, instanceId); if (result.success && result.fileData) { const data = result.fileData.file || result.fileData; if (data?.id) { setPendingFiles(prev => [...prev, { fileId: data.id, fileName: data.fileName || file.name }]); } workspace.refreshFiles(); } }, [fileOps, workspace, instanceId]); const _handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current++; if (e.dataTransfer.types.includes('Files')) setIsDragOver(true); }, []); const _handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDragOver(false); }, []); const _handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const _handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current = 0; setIsDragOver(false); const droppedFiles = e.dataTransfer.files; if (droppedFiles.length > 0) { for (const file of Array.from(droppedFiles)) { await _uploadAndAttach(file); } } }, [_uploadAndAttach]); const _handleFileInputChange = useCallback((e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { Array.from(e.target.files).forEach(file => _uploadAndAttach(file)); e.target.value = ''; } }, [_uploadAndAttach]); const _handleRemovePendingFile = useCallback((fileId: string) => { setPendingFiles(prev => prev.filter(f => f.fileId !== fileId)); }, []); const _handleTreeItemsDrop = useCallback((items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => { setPendingFiles(prev => { const existing = new Set(prev.map(f => f.fileId)); const toAdd: PendingFile[] = []; for (const item of items) { if (!existing.has(item.id)) { toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type }); existing.add(item.id); } } return [...prev, ...toAdd]; }); }, []); if (!instanceId) { return (
No workspace instance selected.
); } const _handleFileSelect = (fileId: string) => { setSelectedFileId(fileId); setRightTab('preview'); setRightCollapsed(false); if (isMobile) { setMobileRightOpen(true); } }; const _handleConversationSelect = (wfId: string) => { workspace.loadWorkflow(wfId); }; const tabButtonStyle = (active: boolean): React.CSSProperties => ({ flex: 1, padding: '6px 0', border: 'none', borderBottom: active ? '2px solid var(--primary-color, #1976d2)' : '2px solid transparent', background: 'none', cursor: 'pointer', fontSize: 11, fontWeight: active ? 600 : 400, color: active ? 'var(--primary-color, #1976d2)' : '#888', textTransform: 'uppercase' as const, }); const _leftPanelBody = ( <>
{leftTab === 'conversations' && ( )} {leftTab === 'files' && ( )} {leftTab === 'datasources' && ( )}
); const _rightPanelBody = ( <>
{!isMobile && ( )}
{rightTab === 'activity' && ( )} {rightTab === 'preview' && ( )}
); return (
{/* Left sidebar */} {!isMobile && !leftCollapsed && ( )} {/* Left resize handle */} {!isMobile && !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')} /> )} {!isMobile && leftCollapsed && (
)} {/* Hidden file input */} {/* Center - Chat + Input */}
{isMobile && (
)} {isDragOver && (
Dateien hier ablegen
)} navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)} /> { const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])]; workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders); setPendingFiles([]); }} isProcessing={workspace.isProcessing} onStop={workspace.stopProcessing} files={workspace.files} dataSources={workspace.dataSources} pendingFiles={pendingFiles} onRemovePendingFile={_handleRemovePendingFile} onFileUploadClick={() => fileInputRef.current?.click()} uploading={fileOps.uploadingFile} selectedProviders={selectedProviders} onProvidersChange={setSelectedProviders} isMobile={isMobile} onTreeItemsDrop={_handleTreeItemsDrop} onPasteAsFile={_uploadAndAttach} />
{/* Right resize handle */} {!isMobile && !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 */} {!isMobile && !rightCollapsed && ( )} {!isMobile && rightCollapsed && (
)} {isMobile && mobileLeftOpen && (
setMobileLeftOpen(false)} >
)} {isMobile && mobileRightOpen && (
setMobileRightOpen(false)} >
)}
); }; export default WorkspacePage;