All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s
642 lines
24 KiB
TypeScript
642 lines
24 KiB
TypeScript
// Copyright (c) 2026 PowerOn AG
|
|
// All rights reserved.
|
|
/**
|
|
* WorkspacePage -- Unified AI Workspace (Prompt-Centric Canvas)
|
|
*
|
|
* Desktop:
|
|
* Top bar: Kontext (links) · aktiver Chat + Auswahl + Neuer Chat (rechts)
|
|
* Stage: optional links gedocktes Kontext-Panel (Daten/Aktivität/Vorschau)
|
|
* + zentrale Chat-Spalte (ChatStream + WorkspaceInput)
|
|
* Mobile: Top bar (Chat + Kontext) · Chat stage · fixed prompt · Bottom-Sheets
|
|
*/
|
|
|
|
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 { ChatsTab } from '../../../components/UnifiedDataBar';
|
|
import type { UdbContext, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar';
|
|
import { FaFolderOpen, FaPlus } from 'react-icons/fa';
|
|
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';
|
|
import { StackLayout } from '../../../components/Layout/StackLayout';
|
|
import { PanelLayout } from '../../../components/Layout/PanelLayout';
|
|
import { FloatingPortal } from '../../../components/UiComponents/FloatingPortal';
|
|
import { WorkspaceContextSidebar, type WorkspaceCtxTab } from './WorkspaceContextSidebar';
|
|
import styles from './WorkspacePage.module.css';
|
|
|
|
interface WorkspacePageProps {
|
|
persistentInstanceId?: string;
|
|
persistentMandateId?: string;
|
|
}
|
|
|
|
export const WorkspacePage: React.FC<WorkspacePageProps> = ({ 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 [searchParams, setSearchParams] = useSearchParams();
|
|
const [udbTab, setUdbTab] = useState<WorkspaceCtxTab>(() => {
|
|
const tab = new URLSearchParams(window.location.search).get('ctxTab');
|
|
if (tab === 'files' || tab === 'sources' || tab === 'activity' || tab === 'preview') return tab;
|
|
if (tab === 'data') return 'files';
|
|
return 'files';
|
|
});
|
|
const [activeChatLabel, setActiveChatLabel] = useState('');
|
|
const [chatPickerOpen, setChatPickerOpen] = useState(false);
|
|
const [ctxPanelOpen, setCtxPanelOpen] = useState(() => {
|
|
try {
|
|
return localStorage.getItem(`workspace-ctx-open-${instanceId}`) === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
|
const workspaceInputRef = useRef<WorkspaceInputHandle>(null);
|
|
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 autoStartHandled = useRef(false);
|
|
const [isMobile, setIsMobile] = useState<boolean>(() =>
|
|
typeof window !== 'undefined' ? window.innerWidth <= 1024 : false,
|
|
);
|
|
const [ctxSidebarCollapsed, setCtxSidebarCollapsed] = useState(false);
|
|
const [mobileChatSheetOpen, setMobileChatSheetOpen] = useState(false);
|
|
const [mobileCtxSheetOpen, setMobileCtxSheetOpen] = useState(false);
|
|
const [mobileCtxSheetExpanded, setMobileCtxSheetExpanded] = useState(false);
|
|
const [chatListRefreshKey, setChatListRefreshKey] = useState(0);
|
|
const chatPickerBtnRef = useRef<HTMLButtonElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!instanceId) return;
|
|
try {
|
|
setCtxPanelOpen(localStorage.getItem(`workspace-ctx-open-${instanceId}`) === '1');
|
|
} catch { /* noop */ }
|
|
}, [instanceId]);
|
|
|
|
const _toggleCtxPanel = useCallback(() => {
|
|
setCtxPanelOpen((prev) => {
|
|
const next = !prev;
|
|
if (next) {
|
|
setCtxSidebarCollapsed(false);
|
|
}
|
|
try {
|
|
localStorage.setItem(`workspace-ctx-open-${instanceId}`, next ? '1' : '0');
|
|
} catch { /* noop */ }
|
|
return next;
|
|
});
|
|
}, [instanceId]);
|
|
|
|
const _setCtxTab = useCallback((tab: WorkspaceCtxTab) => {
|
|
setUdbTab(tab);
|
|
setSearchParams((prev) => {
|
|
const next = new URLSearchParams(prev);
|
|
next.set('ctxTab', tab);
|
|
return next;
|
|
}, { replace: true });
|
|
}, [setSearchParams]);
|
|
|
|
useEffect(() => {
|
|
const _handleResize = () => {
|
|
setIsMobile(window.innerWidth <= 1024);
|
|
};
|
|
_handleResize();
|
|
window.addEventListener('resize', _handleResize);
|
|
return () => window.removeEventListener('resize', _handleResize);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileChatSheetOpen(false);
|
|
setMobileCtxSheetOpen(false);
|
|
setMobileCtxSheetExpanded(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) {
|
|
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<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);
|
|
if (isMobile) {
|
|
setSearchParams((prev) => {
|
|
const next = new URLSearchParams(prev);
|
|
next.set('ctxTab', 'preview');
|
|
return next;
|
|
}, { replace: true });
|
|
setUdbTab('preview');
|
|
setMobileCtxSheetOpen(true);
|
|
} else {
|
|
setCtxSidebarCollapsed(false);
|
|
setCtxPanelOpen(true);
|
|
setUdbTab('preview');
|
|
setSearchParams((prev) => {
|
|
const next = new URLSearchParams(prev);
|
|
next.set('ctxTab', 'preview');
|
|
return next;
|
|
}, { replace: true });
|
|
}
|
|
};
|
|
|
|
const _handleConversationSelect = useCallback((wfId: string, _featureInstanceId?: string, label?: string) => {
|
|
workspace.loadWorkflow(wfId);
|
|
if (label) setActiveChatLabel(label);
|
|
setChatPickerOpen(false);
|
|
setMobileChatSheetOpen(false);
|
|
}, [workspace]);
|
|
|
|
const _handleCreateNewChat = useCallback(() => {
|
|
workspace.resetToNew();
|
|
setActiveChatLabel('');
|
|
setChatPickerOpen(false);
|
|
setMobileChatSheetOpen(false);
|
|
setChatListRefreshKey(k => k + 1);
|
|
}, [workspace]);
|
|
|
|
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,
|
|
});
|
|
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<string>('');
|
|
const [pendingAttachDsLabel, setPendingAttachDsLabel] = useState<string>('');
|
|
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 _contextSidebar = (allowCollapse = true, fillWidth = false) => (
|
|
<WorkspaceContextSidebar
|
|
context={_udbContext}
|
|
activeTab={udbTab}
|
|
onTabChange={_setCtxTab}
|
|
collapsed={ctxSidebarCollapsed}
|
|
onToggleCollapsed={() => setCtxSidebarCollapsed(v => !v)}
|
|
allowCollapse={allowCollapse}
|
|
fillWidth={fillWidth}
|
|
onFileSelect={_handleFileSelect}
|
|
onSourcesChanged={_handleSourcesChanged}
|
|
onSendToChat_Files={_handleSendToChat_Files}
|
|
onSendToChat_FeatureSource={_handleSendToChat_FeatureSource}
|
|
onAttachDataSource={_handleAttachDataSource}
|
|
toolActivities={workspace.toolActivities}
|
|
instanceId={instanceId}
|
|
selectedFileId={selectedFileId}
|
|
files={workspace.files}
|
|
/>
|
|
);
|
|
|
|
const _displayChatLabel = activeChatLabel || '...';
|
|
|
|
const _desktopTopBar = (
|
|
<div className={styles.topBar}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.ctxToggle} ${ctxPanelOpen ? styles.ctxToggleActive : ''}`}
|
|
onClick={_toggleCtxPanel}
|
|
title={t('Kontext')}
|
|
aria-pressed={ctxPanelOpen}
|
|
>
|
|
<FaFolderOpen aria-hidden />
|
|
</button>
|
|
<span className={styles.topBarGrow} />
|
|
<span className={styles.chatName} title={_displayChatLabel}>{_displayChatLabel}</span>
|
|
<div className={styles.chatPickerWrap}>
|
|
<button
|
|
ref={chatPickerBtnRef}
|
|
type="button"
|
|
className={`${styles.iconBtn} ${chatPickerOpen ? styles.iconBtnActive : ''}`}
|
|
onClick={() => setChatPickerOpen(v => !v)}
|
|
title={t('Chat auswählen')}
|
|
aria-expanded={chatPickerOpen}
|
|
>
|
|
<FaFolderOpen aria-hidden />
|
|
</button>
|
|
<FloatingPortal
|
|
open={chatPickerOpen}
|
|
anchorRef={chatPickerBtnRef}
|
|
onClose={() => setChatPickerOpen(false)}
|
|
placement="bottom"
|
|
align="end"
|
|
keepMounted
|
|
>
|
|
<div className={styles.chatPickerDrop}>
|
|
<div className={styles.chatPickerHead}>
|
|
<span>{t('Chats')}</span>
|
|
<button type="button" className={styles.newChatBtn} onClick={_handleCreateNewChat} title={t('Neuer Chat')}>
|
|
<FaPlus aria-hidden />
|
|
</button>
|
|
</div>
|
|
<ChatsTab
|
|
context={_udbContext}
|
|
onSelectChat={_handleConversationSelect}
|
|
activeWorkflowId={workspace.workflowId ?? undefined}
|
|
chatListRefreshKey={chatListRefreshKey}
|
|
onRenameChat={_handleRenameChat}
|
|
onDeleteChat={_handleDeleteChat}
|
|
/>
|
|
</div>
|
|
</FloatingPortal>
|
|
</div>
|
|
<button type="button" className={styles.iconBtn} onClick={_handleCreateNewChat} title={t('Neuer Chat')}>
|
|
<FaPlus aria-hidden />
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
const _centerColumn = (
|
|
<div
|
|
className={styles.centerColumn}
|
|
onDragEnter={_handleDragEnter}
|
|
onDragLeave={_handleDragLeave}
|
|
onDragOver={_handleDragOver}
|
|
onDrop={_handleDrop}
|
|
>
|
|
{isDragOver && (
|
|
<div className={styles.dragOverlay}>
|
|
{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}
|
|
pendingAttachDsLabel={pendingAttachDsLabel}
|
|
onPendingAttachDsConsumed={() => { setPendingAttachDsId(''); setPendingAttachDsLabel(''); }}
|
|
pendingAttachFdsId={pendingAttachFdsId}
|
|
onPendingAttachFdsConsumed={() => setPendingAttachFdsId('')}
|
|
draftAppend={draftAppend}
|
|
onDraftAppendConsumed={() => setDraftAppend('')}
|
|
workflowId={workspace.workflowId}
|
|
loadedAttachedDataSourceIds={workspace.loadedAttachedDataSourceIds}
|
|
loadedAttachedFeatureDataSourceIds={workspace.loadedAttachedFeatureDataSourceIds}
|
|
loadedDataSourceLabels={workspace.loadedDataSourceLabels}
|
|
loadedFeatureDataSourceLabels={workspace.loadedFeatureDataSourceLabels}
|
|
loadedNonce={workspace.loadedNonce}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<StackLayout variant="table">
|
|
<StackLayout.Body className={styles.layoutFill}>
|
|
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
|
|
|
|
{isMobile ? (
|
|
<div className={styles.mobileShell}>
|
|
<div className={styles.mobileTopBar}>
|
|
<button
|
|
type="button"
|
|
className={styles.mobileChatPickerBtn}
|
|
onClick={() => setMobileChatSheetOpen(true)}
|
|
title={t('Chat auswählen')}
|
|
>
|
|
<span>{_displayChatLabel}</span>
|
|
<span aria-hidden>▾</span>
|
|
</button>
|
|
<span className={styles.topBarGrow} />
|
|
<button
|
|
type="button"
|
|
className={`${styles.ctxToggle} ${mobileCtxSheetOpen ? styles.ctxToggleActive : ''}`}
|
|
onClick={() => {
|
|
setCtxSidebarCollapsed(false);
|
|
setMobileCtxSheetOpen(true);
|
|
}}
|
|
title={t('Kontext')}
|
|
>
|
|
<FaFolderOpen aria-hidden />
|
|
</button>
|
|
</div>
|
|
<div className={styles.mobileStage}>
|
|
{_centerColumn}
|
|
</div>
|
|
|
|
{mobileChatSheetOpen && (
|
|
<>
|
|
<div className={styles.mobileSheetOverlay} onClick={() => setMobileChatSheetOpen(false)} />
|
|
<div className={styles.mobileBottomSheet} onClick={e => e.stopPropagation()}>
|
|
<div className={styles.mobileSheetGrab} />
|
|
<div className={styles.mobileSheetHead}>
|
|
<span>{t('Chats')}</span>
|
|
<span className={styles.mobileSheetHeadGrow} />
|
|
<button type="button" className={styles.newChatBtn} onClick={_handleCreateNewChat} title={t('Neuer Chat')}>
|
|
<FaPlus aria-hidden />
|
|
</button>
|
|
<button type="button" className={styles.mobileSheetDoneBtn} onClick={() => setMobileChatSheetOpen(false)}>
|
|
{t('Fertig')}
|
|
</button>
|
|
</div>
|
|
<div className={styles.mobileSheetBody}>
|
|
<ChatsTab
|
|
context={_udbContext}
|
|
onSelectChat={_handleConversationSelect}
|
|
activeWorkflowId={workspace.workflowId ?? undefined}
|
|
chatListRefreshKey={chatListRefreshKey}
|
|
onRenameChat={_handleRenameChat}
|
|
onDeleteChat={_handleDeleteChat}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{mobileCtxSheetOpen && (
|
|
<>
|
|
<div className={styles.mobileSheetOverlay} onClick={() => { setMobileCtxSheetOpen(false); setMobileCtxSheetExpanded(false); }} />
|
|
<div
|
|
className={`${styles.mobileBottomSheet} ${mobileCtxSheetExpanded ? styles.mobileBottomSheetFull : styles.mobileBottomSheetTall}`}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={styles.mobileSheetGrab}
|
|
aria-label={mobileCtxSheetExpanded ? 'Verkleinern' : 'Vollbild'}
|
|
onClick={() => setMobileCtxSheetExpanded(v => !v)}
|
|
/>
|
|
<div className={styles.mobileSheetHead}>
|
|
<span>{t('Kontext')}</span>
|
|
<span className={styles.mobileSheetHeadGrow} />
|
|
<button
|
|
type="button"
|
|
className={styles.mobileSheetExpandBtn}
|
|
onClick={() => setMobileCtxSheetExpanded(v => !v)}
|
|
>
|
|
{mobileCtxSheetExpanded ? '↓' : '↑'}
|
|
</button>
|
|
<button type="button" className={styles.mobileSheetDoneBtn} onClick={() => { setMobileCtxSheetOpen(false); setMobileCtxSheetExpanded(false); }}>
|
|
{t('Fertig')}
|
|
</button>
|
|
</div>
|
|
<div className={styles.mobileSheetBody}>
|
|
{_contextSidebar(false)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className={styles.workspaceShell}>
|
|
{_desktopTopBar}
|
|
<div className={styles.mainStage}>
|
|
{ctxPanelOpen && !ctxSidebarCollapsed ? (
|
|
<PanelLayout
|
|
persistenceKey="workspace-main-split"
|
|
direction="horizontal"
|
|
panes={[
|
|
{ id: 'context', defaultSize: 26, minSize: 16, maxSize: 50, content: _contextSidebar(true, true) },
|
|
{ id: 'chat', defaultSize: 74, content: _centerColumn },
|
|
]}
|
|
/>
|
|
) : (
|
|
<>
|
|
{ctxPanelOpen && _contextSidebar()}
|
|
{_centerColumn}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</StackLayout.Body>
|
|
</StackLayout>
|
|
);
|
|
};
|
|
|
|
export default WorkspacePage;
|