/** * WorkspacePage -- Unified AI Workspace * * 3-column layout: * Left sidebar: UnifiedDataBar (Chats, Files, Sources) * Center: ChatStream + WorkspaceInput * Right sidebar: FilePreview, ToolActivityLog */ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { useNavigate, useParams, useSearchParams } 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 type { WorkspaceInputHandle, TreeItemDrop } from './WorkspaceInput'; import { FilePreview } from './FilePreview'; import { ToolActivityLog } from './ToolActivityLog'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar'; import api from '../../../api'; import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector'; import type { ProviderSelection } from '../../../components/ProviderSelector'; import { useBilling } from '../../../hooks/useBilling'; import { useLanguage } from '../../../providers/language/LanguageContext'; 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 RightTab = 'activity' | 'preview'; interface WorkspacePageProps { persistentInstanceId?: string; persistentMandateId?: string; } export const WorkspacePage: React.FC = ({ persistentInstanceId, persistentMandateId }) => { const { t } = useLanguage(); const { instance } = useCurrentInstance(); const instanceId = persistentInstanceId || instance?.id || ''; const workspace = useWorkspace(instanceId); const fileOps = useFileOperations(); const navigate = useNavigate(); const { mandateId: routeMandateId, featureCode, instanceId: routeInstanceId } = useParams<{ mandateId: string; featureCode: string; instanceId: string; }>(); const mandateId = persistentMandateId || routeMandateId; const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false); const _leftResize = _useResizable(280, 200, 800); const _rightResize = _useResizable(320, 200, 500); const [rightTab, setRightTab] = useState('activity'); const [udbTab, setUdbTab] = useState('chats'); const [selectedFileId, setSelectedFileId] = useState(null); const workspaceInputRef = useRef(null); const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); const { allowedProviders } = useBilling(); const [isDragOver, setIsDragOver] = useState(false); const [draftAppend, setDraftAppend] = useState(''); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); const [searchParams, setSearchParams] = useSearchParams(); const autoStartHandled = useRef(false); const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' ? window.innerWidth <= 1024 : false, ); const [mobileLeftOpen, setMobileLeftOpen] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false); const [chatListRefreshKey, setChatListRefreshKey] = useState(0); 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]); useEffect(() => { if (autoStartHandled.current || !instanceId || workspace.isProcessing) return; const prompt = searchParams.get('prompt'); const autoStart = searchParams.get('autoStart') === 'true'; if (prompt) { autoStartHandled.current = true; setSearchParams({}, { replace: true }); if (autoStart) { const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders); workspace.sendMessage(prompt, [], [], resolvedProviders, []); } else { setDraftAppend(prompt); } } }, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]); const _resolveTreeItemsToFileIds = useCallback(async (items: TreeItemDrop[]) => { const out: string[] = []; for (const it of items) { // Group drops are no longer supported — groups are now presentation-only (view-based) if (it.type !== 'group') { out.push(it.id); } } return [...new Set(out)]; }, []); 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) { workspaceInputRef.current?.attachFileIds([data.id]); } workspace.refreshFiles(); } }, [fileOps, workspace, instanceId]); const _consumeDataTransferFilesOrChat = useCallback(async (dt: React.DragEvent['dataTransfer']) => { const chatId = dt.getData('application/chat-id'); if (chatId) { try { const res = await api.post(`/api/workspace/${instanceId}/resolve-rag`, { chatId }); const body = res.data ?? {}; if (body.summary) setDraftAppend(body.summary); } catch (err) { console.error('RAG resolve failed for dropped chat:', err); } return true; } if (workspaceInputRef.current && (await workspaceInputRef.current.ingestTreeDataTransfer(dt))) { return true; } if (dt.files && dt.files.length > 0) { for (const file of Array.from(dt.files)) { await _uploadAndAttach(file); } return true; } return false; }, [_uploadAndAttach, instanceId]); const _isCenterDropInteresting = useCallback((e: React.DragEvent) => { const types = e.dataTransfer.types; return ( types.includes('application/tree-items') || types.includes('application/group-file-ids') || types.includes('application/group-id') || types.includes('application/porta-group') || types.includes('application/file-id') || types.includes('application/file-ids') || types.includes('application/chat-id') || types.includes('Files') ); }, []); const _handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current++; if (_isCenterDropInteresting(e)) setIsDragOver(true); }, [_isCenterDropInteresting]); const _handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDragOver(false); }, []); const _handleDragOver = useCallback((e: React.DragEvent) => { if (!_isCenterDropInteresting(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; }, [_isCenterDropInteresting]); const _handleDrop = useCallback(async (e: React.DragEvent) => { const alreadyHandled = e.defaultPrevented; e.preventDefault(); e.stopPropagation(); dragCounterRef.current = 0; setIsDragOver(false); if (!alreadyHandled) { await _consumeDataTransferFilesOrChat(e.dataTransfer); } }, [_consumeDataTransferFilesOrChat]); 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 _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => { void workspaceInputRef.current?.attachTreeItems( items.map(i => ({ id: i.id, type: i.type, name: i.name })), ); }, []); if (!instanceId) { return (
{t('Keine Workspace-Instanz ausgewählt.')}
); } const _handleFileSelect = (fileId: string) => { setSelectedFileId(fileId); setRightTab('preview'); setRightCollapsed(false); if (isMobile) { setMobileRightOpen(true); } }; const _handleConversationSelect = (wfId: string) => { workspace.loadWorkflow(wfId); }; const sidebarHeaderBtnStyle: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888', }; const createChatBtnStyle: React.CSSProperties = { ...sidebarHeaderBtnStyle, fontSize: 20, fontWeight: 700, lineHeight: 1, color: 'var(--text-secondary, #555)', }; const _handleCreateNewChat = useCallback(() => { workspace.resetToNew(); setChatListRefreshKey(k => k + 1); }, [workspace]); const tabButtonStyle = (active: boolean): React.CSSProperties => ({ flex: 1, padding: '6px 0', border: 'none', borderBottom: active ? '2px solid var(--primary-color, #F25843)' : '2px solid transparent', background: 'none', cursor: 'pointer', fontSize: 11, fontWeight: active ? 600 : 400, color: active ? 'var(--primary-color, #F25843)' : 'var(--text-tertiary, #888)', textTransform: 'uppercase' as const, }); const _handleRenameChat = useCallback(async (chatId: string, newName: string) => { try { await api.patch(`/api/workspace/${instanceId}/workflows/${chatId}`, { name: newName }); } catch (err) { console.error('Failed to rename chat:', err); } }, [instanceId]); const _handleDeleteChat = useCallback(async (chatId: string) => { try { await api.delete(`/api/workspace/${instanceId}/workflows/${chatId}`); if (workspace.workflowId === chatId) { workspace.resetToNew(); } } catch (err) { console.error('Failed to delete chat:', err); } }, [instanceId, workspace]); const _udbContext: UdbContext = { instanceId: instanceId, mandateId: mandateId, featureInstanceId: instanceId, }; const _handleSourcesChanged = useCallback(() => { workspace.refreshDataSources(); workspace.refreshFeatureDataSources(); }, [workspace]); const [pendingAttachFdsId, setPendingAttachFdsId] = useState(''); const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => { try { const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, { featureInstanceId: params.featureInstanceId, featureCode: params.featureCode, tableName: params.tableName || '', objectKey: params.objectKey, label: params.label, }); const newId = res.data?.id || res.data?.featureDataSource?.id || res.data?.dataSource?.id || ''; if (newId) { await workspace.refreshFeatureDataSources(); setPendingAttachFdsId(newId); } } catch (err) { console.error('Failed to add feature source to chat:', err); } }, [instanceId, workspace]); const [pendingAttachDsId, setPendingAttachDsId] = useState(''); const [pendingAttachDsLabel, setPendingAttachDsLabel] = useState(''); const _handleAttachDataSource = useCallback(async (dsId: string) => { await workspace.refreshDataSources(); setPendingAttachDsId(dsId); }, [workspace]); const _handleDataSourceDrop = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => { try { const res = await api.post(`/api/workspace/${instanceId}/datasources`, { connectionId: params.connectionId, sourceType: params.sourceType, path: params.path, label: params.label, displayPath: params.displayPath || params.label, }); const newId = res.data?.id || res.data?.dataSource?.id; if (newId) { setPendingAttachDsLabel(params.label || params.displayPath || ''); await workspace.refreshDataSources(); setPendingAttachDsId(newId); } } catch (err) { console.error('Failed to drop data source to chat:', err); } }, [instanceId, workspace]); const _leftPanelBody = ( ); 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 = 'var(--primary-color, #F25843)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} /> )} {!isMobile && leftCollapsed && (
)} {/* Hidden file input */} {/* Center - Chat + Input */}
{isMobile && (
)} {isDragOver && (
{t('Dateien oder Gruppen hier ablegen')}
)} navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)} /> { const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders); workspace.sendMessage(prompt, fileIds || [], dataSourceIds, resolvedProviders, featureDataSourceIds, options); }} isProcessing={workspace.isProcessing} onStop={workspace.stopProcessing} files={workspace.files} dataSources={workspace.dataSources} featureDataSources={workspace.featureDataSources} resolveTreeItemsToFileIds={_resolveTreeItemsToFileIds} onFileUploadClick={() => fileInputRef.current?.click()} uploading={fileOps.uploadingFile} providerSelection={providerSelection} onProviderSelectionChange={setProviderSelection} isMobile={isMobile} onFeatureSourceDrop={_handleSendToChat_FeatureSource} onDataSourceDrop={_handleDataSourceDrop} pendingAttachDsId={pendingAttachDsId} pendingAttachDsLabel={pendingAttachDsLabel} onPendingAttachDsConsumed={() => { setPendingAttachDsId(''); setPendingAttachDsLabel(''); }} pendingAttachFdsId={pendingAttachFdsId} onPendingAttachFdsConsumed={() => setPendingAttachFdsId('')} onPasteAsFile={_uploadAndAttach} draftAppend={draftAppend} onDraftAppendConsumed={() => setDraftAppend('')} workflowId={workspace.workflowId} loadedAttachedDataSourceIds={workspace.loadedAttachedDataSourceIds} loadedAttachedFeatureDataSourceIds={workspace.loadedAttachedFeatureDataSourceIds} loadedNonce={workspace.loadedNonce} />
{/* 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 = 'var(--primary-color, #F25843)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} /> )} {/* Right sidebar */} {!isMobile && !rightCollapsed && ( )} {!isMobile && rightCollapsed && (
)} {isMobile && mobileLeftOpen && (
setMobileLeftOpen(false)} >
)} {isMobile && mobileRightOpen && (
setMobileRightOpen(false)} >
)}
); }; export default WorkspacePage;