ui-nyla/src/pages/views/workspace/WorkspacePage.tsx

678 lines
26 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: 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 { collectGroupItemIds } from '../../../api/fileApi';
import type { TableGroupNode } from '../../../api/connectionApi';
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;
}
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 workspaceInputRef = useRef<WorkspaceInputHandle>(null);
/** Persisted grouping tree from /api/files/list — resolves dropped groups → file IDs */
const [filesListGroupTree, setFilesListGroupTree] = useState<TableGroupNode[]>([]);
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]);
const _pullFilesGroupTree = useCallback(async (): Promise<TableGroupNode[]> => {
if (!instanceId) return [];
try {
const res = await api.get<{ groupTree?: TableGroupNode[] }>('/api/files/list', {
params: { page: 1, pageSize: 1 },
});
const gt = res.data?.groupTree;
const list = Array.isArray(gt) ? gt : [];
setFilesListGroupTree(list);
return list;
} catch {
setFilesListGroupTree([]);
return [];
}
}, [instanceId]);
useEffect(() => {
_pullFilesGroupTree();
}, [_pullFilesGroupTree]);
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[]) => {
let tree = filesListGroupTree;
if (items.some(i => i.type === 'group')) {
tree = await _pullFilesGroupTree();
}
const out: string[] = [];
for (const it of items) {
if (it.type === 'group') {
out.push(...collectGroupItemIds(tree, it.id));
} else {
out.push(it.id);
}
}
return [...new Set(out)];
}, [filesListGroupTree, _pullFilesGroupTree]);
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) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragOver(false);
await _consumeDataTransferFilesOrChat(e.dataTransfer);
}, [_consumeDataTransferFilesOrChat]);
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 _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 (
<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 [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',
}}>
{t('Dateien oder Gruppen 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
ref={workspaceInputRef}
instanceId={instanceId}
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
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}
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;