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

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;