650 lines
25 KiB
TypeScript
650 lines
25 KiB
TypeScript
/**
|
||
* 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 { 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 PendingFile {
|
||
fileId: string;
|
||
fileName: string;
|
||
itemType?: 'file' | 'folder';
|
||
}
|
||
|
||
interface WorkspacePageProps {
|
||
persistentInstanceId?: string;
|
||
}
|
||
|
||
export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstanceId }) => {
|
||
const { t } = useLanguage();
|
||
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 [rightTab, setRightTab] = useState<RightTab>('activity');
|
||
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
|
||
const { allowedProviders } = useBilling();
|
||
const [isDragOver, setIsDragOver] = useState(false);
|
||
const [draftAppend, setDraftAppend] = useState('');
|
||
const dragCounterRef = useRef(0);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const autoStartHandled = useRef(false);
|
||
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]);
|
||
|
||
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 _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 chatId = e.dataTransfer.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;
|
||
}
|
||
|
||
const droppedFiles = e.dataTransfer.files;
|
||
if (droppedFiles.length > 0) {
|
||
for (const file of Array.from(droppedFiles)) {
|
||
await _uploadAndAttach(file);
|
||
}
|
||
}
|
||
}, [_uploadAndAttach, instanceId, workspace]);
|
||
|
||
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' }}>
|
||
{t('Keine Workspace-Instanz ausgewählt.')}
|
||
</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, #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 _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => {
|
||
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];
|
||
});
|
||
}, []);
|
||
|
||
const [pendingAttachFdsId, setPendingAttachFdsId] = useState<string>('');
|
||
|
||
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,
|
||
});
|
||
// Backend response shape parity with /datasources — accept either a flat
|
||
// ``id`` or a wrapped ``featureDataSource.id`` so a future API tweak
|
||
// doesn't silently break the chip again.
|
||
const newId =
|
||
res.data?.id ||
|
||
res.data?.featureDataSource?.id ||
|
||
res.data?.dataSource?.id ||
|
||
'';
|
||
workspace.refreshFeatureDataSources();
|
||
if (newId) {
|
||
setPendingAttachFdsId(newId);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to add feature source to chat:', err);
|
||
}
|
||
}, [instanceId, workspace]);
|
||
|
||
const [pendingAttachDsId, setPendingAttachDsId] = useState<string>('');
|
||
const _handleAttachDataSource = useCallback((dsId: string) => {
|
||
setPendingAttachDsId(dsId);
|
||
}, []);
|
||
|
||
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) {
|
||
setPendingAttachDsId(newId);
|
||
workspace.refreshDataSources();
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to drop data source to chat:', err);
|
||
}
|
||
}, [instanceId, workspace]);
|
||
|
||
const _leftPanelBody = (
|
||
<UnifiedDataBar
|
||
context={_udbContext}
|
||
activeTab={udbTab}
|
||
onTabChange={setUdbTab}
|
||
onSelectChat={_handleConversationSelect}
|
||
activeWorkflowId={workspace.workflowId ?? undefined}
|
||
onCreateNewChat={workspace.resetToNew}
|
||
onRenameChat={_handleRenameChat}
|
||
onDeleteChat={_handleDeleteChat}
|
||
onFileSelect={_handleFileSelect}
|
||
onSourcesChanged={_handleSourcesChanged}
|
||
onSendToChat_Files={_handleSendToChat_Files}
|
||
onSendToChat_FeatureSource={_handleSendToChat_FeatureSource}
|
||
onAttachDataSource={_handleAttachDataSource}
|
||
/>
|
||
);
|
||
|
||
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')}>{t('Aktivität')}</button>
|
||
<button style={tabButtonStyle(rightTab === 'preview')} onClick={() => setRightTab('preview')}>{t('Vorschau')}</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 }}>{t('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 = 'var(--primary-color, #F25843)')}
|
||
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,
|
||
}}
|
||
>
|
||
{t('Workspace')}
|
||
</button>
|
||
<button
|
||
onClick={() => { setRightTab('activity'); setMobileRightOpen(true); }}
|
||
style={{
|
||
padding: '6px 10px',
|
||
borderRadius: 8,
|
||
border: '1px solid var(--border-color, #ddd)',
|
||
background: rightTab === 'activity' ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.1))' : '#f7f7f7',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{t('Aktivität')}
|
||
</button>
|
||
<button
|
||
onClick={() => { setRightTab('preview'); setMobileRightOpen(true); }}
|
||
style={{
|
||
padding: '6px 10px',
|
||
borderRadius: 8,
|
||
border: '1px solid var(--border-color, #ddd)',
|
||
background: rightTab === 'preview' ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.1))' : '#f7f7f7',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{t('Vorschau')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isDragOver && (
|
||
<div style={{
|
||
position: 'absolute', inset: 0, zIndex: 100,
|
||
background: 'var(--primary-dark-bg, rgba(242, 88, 67, 0.1))',
|
||
border: '2px dashed var(--primary-color, #F25843)', borderRadius: 8,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 16, fontWeight: 600, color: 'var(--primary-color, #F25843)',
|
||
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, featureDataSourceIds, options) => {
|
||
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
|
||
const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders);
|
||
workspace.sendMessage(prompt, allFileIds, dataSourceIds, resolvedProviders, featureDataSourceIds, options);
|
||
setPendingFiles([]);
|
||
}}
|
||
isProcessing={workspace.isProcessing}
|
||
onStop={workspace.stopProcessing}
|
||
files={workspace.files}
|
||
dataSources={workspace.dataSources}
|
||
featureDataSources={workspace.featureDataSources}
|
||
pendingFiles={pendingFiles}
|
||
onRemovePendingFile={_handleRemovePendingFile}
|
||
onFileUploadClick={() => fileInputRef.current?.click()}
|
||
uploading={fileOps.uploadingFile}
|
||
providerSelection={providerSelection}
|
||
onProviderSelectionChange={setProviderSelection}
|
||
isMobile={isMobile}
|
||
onTreeItemsDrop={_handleTreeItemsDrop}
|
||
onFeatureSourceDrop={_handleSendToChat_FeatureSource}
|
||
onDataSourceDrop={_handleDataSourceDrop}
|
||
pendingAttachDsId={pendingAttachDsId}
|
||
onPendingAttachDsConsumed={() => setPendingAttachDsId('')}
|
||
pendingAttachFdsId={pendingAttachFdsId}
|
||
onPendingAttachFdsConsumed={() => setPendingAttachFdsId('')}
|
||
onPasteAsFile={_uploadAndAttach}
|
||
draftAppend={draftAppend}
|
||
onDraftAppendConsumed={() => setDraftAppend('')}
|
||
workflowId={workspace.workflowId}
|
||
loadedAttachedDataSourceIds={workspace.loadedAttachedDataSourceIds}
|
||
loadedAttachedFeatureDataSourceIds={workspace.loadedAttachedFeatureDataSourceIds}
|
||
loadedNonce={workspace.loadedNonce}
|
||
/>
|
||
</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 = 'var(--primary-color, #F25843)')}
|
||
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 }}>{t('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' ? t('Aktivität') : t('Vorschau')}</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;
|