frontend_nyla/src/pages/views/workspace/WorkspacePage.tsx
2026-03-17 22:51:36 +01:00

520 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<WorkspacePageProps> = ({ 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<LeftTab>('conversations');
const [rightTab, setRightTab] = useState<RightTab>('activity');
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isMobile, setIsMobile] = useState<boolean>(() =>
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<HTMLInputElement>) => {
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 (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
No workspace instance selected.
</div>
);
}
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 = (
<>
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<button style={tabButtonStyle(leftTab === 'conversations')} onClick={() => setLeftTab('conversations')}>Chats</button>
<button style={tabButtonStyle(leftTab === 'files')} onClick={() => setLeftTab('files')}>Files</button>
<button style={tabButtonStyle(leftTab === 'datasources')} onClick={() => setLeftTab('datasources')}>Sources</button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{leftTab === 'conversations' && (
<ConversationList
instanceId={instanceId}
activeWorkflowId={workspace.workflowId}
onSelect={_handleConversationSelect}
onCreateNew={workspace.resetToNew}
refreshTrigger={workspace.workflowVersion}
/>
)}
{leftTab === 'files' && (
<FileBrowser
instanceId={instanceId}
files={workspace.files}
onRefresh={workspace.refreshFiles}
onFileSelect={_handleFileSelect}
/>
)}
{leftTab === 'datasources' && (
<DataSourcePanel
instanceId={instanceId}
dataSources={workspace.dataSources}
onRefresh={workspace.refreshDataSources}
/>
)}
</div>
</>
);
const _rightPanelBody = (
<>
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button style={tabButtonStyle(rightTab === 'activity')} onClick={() => setRightTab('activity')}>Activity</button>
<button style={tabButtonStyle(rightTab === 'preview')} onClick={() => setRightTab('preview')}>Preview</button>
</div>
{!isMobile && (
<button onClick={() => setRightCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
)}
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{rightTab === 'activity' && (
<ToolActivityLog activities={workspace.toolActivities} />
)}
{rightTab === 'preview' && (
<FilePreview
instanceId={instanceId}
fileId={selectedFileId}
files={workspace.files}
/>
)}
</div>
</>
);
return (
<div style={{ display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
{/* Left sidebar */}
{!isMobile && !leftCollapsed && (
<aside style={{
width: _leftResize.width,
minWidth: 200,
borderRight: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
flexShrink: 0,
}}>
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Workspace</span>
<button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
{_leftPanelBody}
</aside>
)}
{/* Left resize handle */}
{!isMobile && !leftCollapsed && (
<div
onMouseDown={e => _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 && (
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderRight: '1px solid var(--border-color, #e0e0e0)' }}>
<button onClick={() => setLeftCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
)}
{/* Hidden file input */}
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
{/* Center - Chat + Input */}
<main
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, minHeight: 0, position: 'relative' }}
onDragEnter={_handleDragEnter}
onDragLeave={_handleDragLeave}
onDragOver={_handleDragOver}
onDrop={_handleDrop}
>
{isMobile && (
<div style={{
display: 'flex',
gap: 8,
padding: '8px 12px',
borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-primary, #fff)',
flexWrap: 'wrap',
}}>
<button
onClick={() => setMobileLeftOpen(true)}
style={{
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
background: '#f7f7f7',
cursor: 'pointer',
fontSize: 12,
fontWeight: 600,
}}
>
Workspace
</button>
<button
onClick={() => { setRightTab('activity'); setMobileRightOpen(true); }}
style={{
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
background: rightTab === 'activity' ? '#e8f3ff' : '#f7f7f7',
cursor: 'pointer',
fontSize: 12,
}}
>
Activity
</button>
<button
onClick={() => { setRightTab('preview'); setMobileRightOpen(true); }}
style={{
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
background: rightTab === 'preview' ? '#e8f3ff' : '#f7f7f7',
cursor: 'pointer',
fontSize: 12,
}}
>
Preview
</button>
</div>
)}
{isDragOver && (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #1976d2', borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16, fontWeight: 600, color: '#1976d2',
pointerEvents: 'none',
}}>
Dateien hier ablegen
</div>
)}
<ChatStream
messages={workspace.messages}
agentProgress={workspace.agentProgress}
isProcessing={workspace.isProcessing}
pendingEdits={workspace.pendingEdits}
onAcceptEdit={workspace.acceptEdit}
onRejectEdit={workspace.rejectEdit}
onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)}
/>
<WorkspaceInput
instanceId={instanceId}
onSend={(prompt, fileIds, dataSourceIds) => {
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}
/>
</main>
{/* Right resize handle */}
{!isMobile && !rightCollapsed && (
<div
onMouseDown={e => _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 && (
<aside style={{
width: _rightResize.width,
minWidth: 200,
borderLeft: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
flexShrink: 0,
}}>
{_rightPanelBody}
</aside>
)}
{!isMobile && rightCollapsed && (
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderLeft: '1px solid var(--border-color, #e0e0e0)' }}>
<button onClick={() => setRightCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
)}
{isMobile && mobileLeftOpen && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0, 0, 0, 0.35)',
zIndex: 120,
display: 'flex',
}}
onClick={() => setMobileLeftOpen(false)}
>
<aside
style={{
width: '100%',
maxWidth: 460,
height: '100%',
background: 'var(--bg-primary, #fff)',
borderRight: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={e => e.stopPropagation()}
>
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Workspace</span>
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
</div>
{_leftPanelBody}
</aside>
</div>
)}
{isMobile && mobileRightOpen && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0, 0, 0, 0.35)',
zIndex: 120,
display: 'flex',
justifyContent: 'flex-end',
}}
onClick={() => setMobileRightOpen(false)}
>
<aside
style={{
width: '100%',
maxWidth: 460,
height: '100%',
background: 'var(--bg-primary, #fff)',
borderLeft: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={e => e.stopPropagation()}
>
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{rightTab === 'activity' ? 'Activity' : 'Preview'}</span>
<button onClick={() => setMobileRightOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
</div>
{_rightPanelBody}
</aside>
</div>
)}
</div>
);
};
export default WorkspacePage;