From 9513e44b0cc467f5db068613254ee92fdbe22105 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 17 Mar 2026 22:51:36 +0100 Subject: [PATCH] added monaco editor --- package-lock.json | 65 ++ package.json | 1 + src/App.tsx | 19 +- src/api/workflowApi.ts | 16 +- src/components/FolderTree/FolderTree.tsx | 2 +- .../ProviderSelector.module.css | 2 +- .../ProviderSelector/ProviderSelector.tsx | 2 +- src/config/pageRegistry.tsx | 3 +- src/hooks/playground/playgroundUtils.ts | 62 -- src/hooks/playground/useDashboardInputForm.ts | 849 ------------------ src/hooks/playground/useDashboardLogTree.ts | 354 -------- src/hooks/playground/useWorkflowLifecycle.ts | 558 ------------ src/hooks/playground/useWorkflowOperations.ts | 2 - src/hooks/playground/useWorkflowPolling.ts | 205 ----- src/hooks/playground/useWorkflows.ts | 92 -- src/hooks/usePlayground.ts | 15 +- src/hooks/useWorkflows.ts | 1 - src/locales/de.ts | 5 - src/locales/en.ts | 5 - src/locales/fr.ts | 5 - src/pages/FeatureView.tsx | 20 +- src/pages/Store.tsx | 14 +- .../views/codeeditor/CodeEditor.module.css | 496 ---------- src/pages/views/codeeditor/CodeEditorPage.tsx | 226 ----- .../codeeditor/CodeEditorWorkflowsPage.tsx | 83 -- .../views/codeeditor/DiffPreviewPanel.tsx | 95 -- src/pages/views/codeeditor/FileListPanel.tsx | 110 --- src/pages/views/codeeditor/index.ts | 2 - src/pages/views/codeeditor/useCodeEditor.ts | 260 ------ .../trustee/TrusteeExpenseImportView.tsx | 2 +- .../views/trustee/TrusteeScanUploadView.tsx | 4 +- src/pages/views/workspace/ChatStream.tsx | 62 +- .../views/workspace/WorkspaceEditorPage.tsx | 278 ++++++ src/pages/views/workspace/WorkspacePage.tsx | 6 + src/pages/views/workspace/useWorkspace.ts | 54 +- .../views/workspace/useWorkspaceEditor.ts | 127 +++ src/pages/workflows/PlaygroundPage.module.css | 593 ------------ src/pages/workflows/PlaygroundPage.tsx | 811 ----------------- src/pages/workflows/WorkflowPages.module.css | 298 ------ src/pages/workflows/WorkflowsPage.tsx | 262 ------ src/pages/workflows/index.ts | 3 - src/types/mandate.ts | 19 +- src/utils/sseClient.ts | 9 +- 43 files changed, 590 insertions(+), 5507 deletions(-) delete mode 100644 src/hooks/playground/playgroundUtils.ts delete mode 100644 src/hooks/playground/useDashboardInputForm.ts delete mode 100644 src/hooks/playground/useDashboardLogTree.ts delete mode 100644 src/hooks/playground/useWorkflowLifecycle.ts delete mode 100644 src/hooks/playground/useWorkflowOperations.ts delete mode 100644 src/hooks/playground/useWorkflowPolling.ts delete mode 100644 src/hooks/playground/useWorkflows.ts delete mode 100644 src/pages/views/codeeditor/CodeEditor.module.css delete mode 100644 src/pages/views/codeeditor/CodeEditorPage.tsx delete mode 100644 src/pages/views/codeeditor/CodeEditorWorkflowsPage.tsx delete mode 100644 src/pages/views/codeeditor/DiffPreviewPanel.tsx delete mode 100644 src/pages/views/codeeditor/FileListPanel.tsx delete mode 100644 src/pages/views/codeeditor/index.ts delete mode 100644 src/pages/views/codeeditor/useCodeEditor.ts create mode 100644 src/pages/views/workspace/WorkspaceEditorPage.tsx create mode 100644 src/pages/views/workspace/useWorkspaceEditor.ts delete mode 100644 src/pages/workflows/PlaygroundPage.module.css delete mode 100644 src/pages/workflows/PlaygroundPage.tsx delete mode 100644 src/pages/workflows/WorkflowPages.module.css delete mode 100644 src/pages/workflows/WorkflowsPage.tsx delete mode 100644 src/pages/workflows/index.ts diff --git a/package-lock.json b/package-lock.json index 7792924..7cfcea4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@azure/msal-browser": "^4.12.0", "@azure/msal-react": "^3.0.12", + "@monaco-editor/react": "^4.7.0", "@types/leaflet": "^1.9.21", "@xstate/react": "^5.0.0", "axios": "^1.8.3", @@ -1043,6 +1044,27 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1642,6 +1664,13 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true, + "peer": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2809,6 +2838,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -4397,6 +4435,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5354,6 +5404,16 @@ "node": "*" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/motion": { "version": "12.23.9", "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.9.tgz", @@ -6613,6 +6673,11 @@ "node": ">= 10.x" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/package.json b/package.json index ca11c58..456aebc 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@azure/msal-browser": "^4.12.0", "@azure/msal-react": "^3.0.12", + "@monaco-editor/react": "^4.7.0", "@types/leaflet": "^1.9.21", "@xstate/react": "^5.0.0", "axios": "^1.8.3", diff --git a/src/App.tsx b/src/App.tsx index ac4ab76..0789bba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,7 +40,6 @@ import StorePage from './pages/Store'; import { FeatureViewPage } from './pages/FeatureView'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; -import { PlaygroundPage } from './pages/workflows'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { BillingDataView, BillingAdmin } from './pages/billing'; function App() { @@ -100,18 +99,6 @@ function App() { } /> } /> - {/* ============================================== */} - {/* WORKFLOWS ROUTES (deprecated - redirect to /) */} - {/* Workflows are accessed via feature routes: */} - {/* /mandates/:mandateId/chatplayground/:id/workflows */} - {/* /mandates/:mandateId/automation/:id/definitions */} - {/* ============================================== */} - - } /> - } /> - } /> - - {/* ============================================== */} {/* BASISDATEN ROUTES (global) */} {/* ============================================== */} @@ -162,16 +149,12 @@ function App() { } /> } /> - {/* Chat Playground Feature Views */} - } /> - } /> - {/* Automation Feature Views */} } /> } /> } /> - {/* Code Editor Feature Views */} + {/* Workspace Editor */} } /> {/* Teams Bot Feature Views */} diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index ce902c1..fc890d5 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -237,7 +237,7 @@ export async function fetchWorkflowLogs( /** * Fetch unified chat data (messages, logs, stats, documents) - * Endpoint: GET /api/chatplayground/{instanceId}/workflows/{workflowId}/chatData + * Endpoint: GET /api/automations/{instanceId}/workflows/{workflowId}/chatData * Query params: afterTimestamp (optional) - fetch only data created after this time */ export async function fetchChatData( @@ -248,7 +248,7 @@ export async function fetchChatData( ): Promise { const params = afterTimestamp ? { afterTimestamp: afterTimestamp.toString() } : undefined; const requestConfig = { - url: `/api/chatplayground/${instanceId}/workflows/${workflowId}/chatData`, + url: `/api/automations/${instanceId}/workflows/${workflowId}/chatData`, method: 'get' as const, params }; @@ -303,7 +303,7 @@ export async function fetchChatData( /** * Start a new workflow or continue an existing one - * Endpoint: POST /api/chatplayground/{instanceId}/start + * Endpoint: POST /api/automations/{instanceId}/start * Query params: workflowId (optional), workflowMode (default: "Dynamic") */ export async function startWorkflowApi( @@ -318,7 +318,6 @@ export async function startWorkflowApi( if (options?.workflowMode) { params.workflowMode = options.workflowMode; } else { - // Default to 'Dynamic' if not provided (though it should always be provided) params.workflowMode = 'Dynamic'; } @@ -326,7 +325,6 @@ export async function startWorkflowApi( params.workflowId = options.workflowId; } - // Request body uses 'prompt' field (not 'input') according to API spec const requestBody: any = { prompt: workflowData.prompt, ...(workflowData.listFileId && workflowData.listFileId.length > 0 && { listFileId: workflowData.listFileId }), @@ -336,10 +334,10 @@ export async function startWorkflowApi( }; const requestConfig = { - url: `/api/chatplayground/${instanceId}/start`, + url: `/api/automations/${instanceId}/start`, method: 'post' as const, data: requestBody, - params: params // Always include workflowMode + params: params }; // Log full request details @@ -359,7 +357,7 @@ export async function startWorkflowApi( /** * Stop a running workflow - * Endpoint: POST /api/chatplayground/{instanceId}/workflows/{workflowId}/stop + * Endpoint: POST /api/automations/{instanceId}/workflows/{workflowId}/stop */ export async function stopWorkflowApi( request: ApiRequestFunction, @@ -367,7 +365,7 @@ export async function stopWorkflowApi( workflowId: string ): Promise { await request({ - url: `/api/chatplayground/${instanceId}/workflows/${workflowId}/stop`, + url: `/api/automations/${instanceId}/workflows/${workflowId}/stop`, method: 'post' }); } diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 8a3579f..11665a2 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -596,7 +596,7 @@ export default function FolderTree({ e.dataTransfer.setData('application/folder-id', id); } } - e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.effectAllowed = 'copyMove'; }, [selectedItemIds, flatList]); const allFileIds = useMemo(() => { diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css index ddfa9c9..04d2be7 100644 --- a/src/components/ProviderSelector/ProviderSelector.module.css +++ b/src/components/ProviderSelector/ProviderSelector.module.css @@ -46,7 +46,7 @@ display: inline-block; } -/* Trigger Button - matches iconButton style from PlaygroundPage */ +/* Trigger Button */ .triggerButton { display: flex; align-items: center; diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx index 1e8e864..e41cefb 100644 --- a/src/components/ProviderSelector/ProviderSelector.tsx +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -2,7 +2,7 @@ * ProviderSelector Component * * Wiederverwendbare Komponente zur Auswahl von AICore-Providern. - * Kann im Chat Playground und Automation Editor verwendet werden. + * Kann im AI Workspace und Automation Editor verwendet werden. * * Features: * - Dropdown für Einzelauswahl diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index f68c91c..21a8b40 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -110,8 +110,6 @@ export const PAGE_ICONS: Record = { 'feature.trustee': , 'feature.realestate': , 'feature.chatworkflow': , - 'feature.chatplayground': , - 'feature.codeeditor': , 'feature.automation': , 'page.feature.chatbot.conversations': , 'feature.chatbot': , @@ -119,6 +117,7 @@ export const PAGE_ICONS: Record = { // Feature pages - Workspace 'page.feature.workspace.dashboard': , + 'page.feature.workspace.editor': , 'feature.workspace': , }; diff --git a/src/hooks/playground/playgroundUtils.ts b/src/hooks/playground/playgroundUtils.ts deleted file mode 100644 index 4fda891..0000000 --- a/src/hooks/playground/playgroundUtils.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { MessageDocument } from '../../components/UiComponents/Messages/MessagesTypes'; -import type { WorkflowMessage, WorkflowLog } from '../../api/workflowApi'; - -export const sortMessages = (a: WorkflowMessage, b: WorkflowMessage) => { - if (a.publishedAt !== undefined && b.publishedAt !== undefined) { - return a.publishedAt - b.publishedAt; - } - if (a.publishedAt !== undefined) return -1; - if (b.publishedAt !== undefined) return 1; - if (a.sequenceNr !== undefined && b.sequenceNr !== undefined) { - return a.sequenceNr - b.sequenceNr; - } - return 0; -}; - -export const sortLogs = (a: WorkflowLog, b: WorkflowLog) => { - if (a.timestamp !== undefined && b.timestamp !== undefined) { - return a.timestamp - b.timestamp; - } - if (a.publishedAt !== undefined && b.publishedAt !== undefined) { - return a.publishedAt - b.publishedAt; - } - if (a.sequenceNr !== undefined && b.sequenceNr !== undefined) { - return a.sequenceNr - b.sequenceNr; - } - return 0; -}; - -export const extractFileIdsFromMessage = (message: WorkflowMessage): Set => { - const fileIds = new Set(); - const documents = (message as any).documents as MessageDocument[] | undefined; - const files = (message as any).files as any[] | undefined; - - if (documents && Array.isArray(documents)) { - documents.forEach((doc: MessageDocument) => { - if (doc.fileId) fileIds.add(doc.fileId); - }); - } - if (files && Array.isArray(files)) { - files.forEach((file: any) => { - const fileId = file.id || file.fileId; - if (fileId) fileIds.add(fileId); - }); - } - return fileIds; -}; - -export const convertFilesToDocuments = (files: any[], messageId: string): MessageDocument[] => { - return files.map((file: any) => ({ - id: file.id || file.fileId || file.file_id, - fileId: file.id || file.fileId || file.file_id, - fileName: file.fileName || file.name || file.file_name || 'Unknown File', - fileSize: file.fileSize || file.size || 0, - mimeType: file.mimeType || file.mime_type || 'application/octet-stream', - messageId, - roundNumber: 0, - taskNumber: 0, - actionNumber: 0, - actionId: '' - })); -}; - diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts deleted file mode 100644 index cd5fec0..0000000 --- a/src/hooks/playground/useDashboardInputForm.ts +++ /dev/null @@ -1,849 +0,0 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { useApiRequest } from '../useApi'; -import { useWorkflowSelection } from '../../contexts/WorkflowSelectionContext'; -import { useFileContext } from '../../contexts/FileContext'; -import { MessageDocument } from '../../components/UiComponents/Messages/MessagesTypes'; -import { usePrompts } from '../usePrompts'; -import { usePermissions } from '../usePermissions'; -import { deleteFileFromMessageApi, deleteMessageApi } from '../../api/workflowApi'; -import type { Workflow, WorkflowMessage } from '../../api/workflowApi'; -import { useWorkflowLifecycle } from './useWorkflowLifecycle'; -import { useWorkflows } from './useWorkflows'; -import { useDashboardLogTree } from './useDashboardLogTree'; -import { convertFilesToDocuments, sortMessages } from './playgroundUtils'; -import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes'; - -export interface WorkflowFile { - id: string; - fileId: string; - fileName: string; - fileSize: number; - mimeType: string; - messageId?: string; - source?: 'user_uploaded' | 'ai_created'; -} - -export function useDashboardInputForm(instanceId: string) { - const [inputValue, setInputValue] = useState(''); - const [pendingFiles, setPendingFiles] = useState([]); - const [isFileAttachmentPopupOpen, setIsFileAttachmentPopupOpen] = useState(false); - const [optimisticMessage, setOptimisticMessage] = useState(null); - const [selectedPromptId, setSelectedPromptId] = useState(null); - const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null); - const [selectedProviders, setSelectedProviders] = useState([]); // AI provider selection (multiselect) - const [deletedDocumentFileIds, setDeletedDocumentFileIds] = useState>(new Set()); - const [deletedMessageIds, setDeletedMessageIds] = useState>(new Set()); - const [deletingMessages, setDeletingMessages] = useState>(new Set()); - - const { checkPermission } = usePermissions(); - const [playgroundUIPermission, setPlaygroundUIPermission] = useState(true); - const [chatWorkflowPermission, setChatWorkflowPermission] = useState(null); - const [promptPermission, setPromptPermission] = useState(null); - const [filePermission, setFilePermission] = useState(null); - - const { selectedWorkflowId, selectWorkflow: selectWorkflowFromContext, clearWorkflow: clearWorkflowFromContext } = useWorkflowSelection(); - const { - workflowId, - workflowStatus, - currentRound, - isRunning, - isStopping, - startingWorkflow, - messages, - dashboardLogs, - unifiedContentLogs, - latestStats, - startWorkflow, - stopWorkflow, - resetWorkflow, - selectWorkflow, - setWorkflowStatusOptimistic - } = useWorkflowLifecycle(instanceId); - - // Dashboard log tree hook - const { - tree: dashboardTree, - processDashboardLogs, - clearDashboard, - toggleOperationExpanded, - toggleRoundExpanded, - updateCurrentRound, - getChildOperations - } = useDashboardLogTree(); - - // Ref to prevent infinite sync loops - const isSyncingRef = useRef(false); - - const fileContext = useFileContext(); - const { request } = useApiRequest(); - const { prompts, loading: promptsLoading, permissions: promptsPermissions, fetchPromptById } = usePrompts(); - - useEffect(() => { - if (promptsPermissions) { - setPromptPermission(promptsPermissions); - } - }, [promptsPermissions]); - - useEffect(() => { - const checkPermissions = async () => { - try { - // UI permission is already verified by the navigation/routing layer - // (FeatureAccess + instance role checked before page is reachable). - // We set it to true and load DATA permissions directly. - setPlaygroundUIPermission(true); - - const chatWorkflowPerm = await checkPermission('DATA', 'ChatWorkflow'); - setChatWorkflowPermission(chatWorkflowPerm); - const promptPerm = await checkPermission('DATA', 'Prompt'); - setPromptPermission(promptPerm); - const filePerm = await checkPermission('DATA', 'FileItem'); - setFilePermission(filePerm); - } catch (error) { - } - }; - - checkPermissions(); - }, [checkPermission]); - - // Sync context -> lifecycle: When context selection changes, update lifecycle - useEffect(() => { - if (isSyncingRef.current) return; - - if (selectedWorkflowId && selectedWorkflowId !== workflowId) { - isSyncingRef.current = true; - selectWorkflow(selectedWorkflowId).finally(() => { - isSyncingRef.current = false; - }); - } else if (!selectedWorkflowId && workflowId) { - // If context is cleared but lifecycle still has a workflow, reset lifecycle - isSyncingRef.current = true; - resetWorkflow(); - isSyncingRef.current = false; - } - }, [selectedWorkflowId, workflowId, selectWorkflow, resetWorkflow]); - - // Sync lifecycle -> context: When lifecycle workflowId changes, update context - useEffect(() => { - if (isSyncingRef.current) return; - - if (workflowId && workflowId !== selectedWorkflowId) { - isSyncingRef.current = true; - selectWorkflowFromContext(workflowId); - isSyncingRef.current = false; - } else if (!workflowId && selectedWorkflowId) { - // If lifecycle is cleared but context still has selection, clear context - isSyncingRef.current = true; - clearWorkflowFromContext(); - isSyncingRef.current = false; - } - }, [workflowId, selectedWorkflowId, selectWorkflowFromContext, clearWorkflowFromContext]); - - useEffect(() => { - const handleSetInput = (event: CustomEvent<{ value: string }>) => { - const newValue = event.detail.value; - if (newValue && typeof newValue === 'string') { - setInputValue(newValue); - } - }; - - window.addEventListener('dashboardSetInput', handleSetInput as EventListener); - return () => { - window.removeEventListener('dashboardSetInput', handleSetInput as EventListener); - }; - }, []); - - const { workflows, loading: workflowsLoading, refetch: refetchWorkflows } = useWorkflows(instanceId); - - // Track processed log IDs to avoid reprocessing - const processedLogIdsRef = useRef>(new Set()); - const lastWorkflowIdRef = useRef(null); - const lastDashboardLogsLengthRef = useRef(0); - - // Clear processed logs when workflow changes - useEffect(() => { - if (workflowId !== lastWorkflowIdRef.current) { - processedLogIdsRef.current.clear(); - lastWorkflowIdRef.current = workflowId || null; - lastDashboardLogsLengthRef.current = 0; - if (!workflowId) { - clearDashboard(true); - } - } - }, [workflowId, clearDashboard]); - - // Process dashboard logs when they change (only new logs) - useEffect(() => { - if (!dashboardLogs || dashboardLogs.length === 0) { - lastDashboardLogsLengthRef.current = 0; - return; - } - - // Only process if the array length changed (indicating new logs) - if (dashboardLogs.length === lastDashboardLogsLengthRef.current) { - return; - } - - // Filter to only new logs that haven't been processed - const newLogs = dashboardLogs.filter(log => { - const logId = log.id || `${log.operationId}-${log.timestamp}`; - if (processedLogIdsRef.current.has(logId)) { - return false; - } - processedLogIdsRef.current.add(logId); - return true; - }); - - // Only process if there are new logs - if (newLogs.length > 0) { - // Convert API WorkflowLog format to LogTypes WorkflowLog format - const convertedLogs: LogTypesWorkflowLog[] = newLogs.map(log => ({ - id: log.id || `${log.operationId || 'unknown'}-${log.timestamp || Date.now()}`, - workflowId: log.workflowId || '', - message: log.message || '', - type: log.type, - timestamp: log.timestamp || Date.now(), - status: log.status, - progress: log.progress, - performance: log.performance, - parentId: log.parentId, - operationId: log.operationId - })); - processDashboardLogs(convertedLogs); - } - - lastDashboardLogsLengthRef.current = dashboardLogs.length; - }, [dashboardLogs, processDashboardLogs]); - - // Update current round in dashboard tree when it changes - useEffect(() => { - if (currentRound !== undefined) { - updateCurrentRound(currentRound); - } - }, [currentRound, updateCurrentRound]); - - const workflowFiles = useMemo(() => { - const fileMap = new Map(); - const pendingFileIds = new Set(pendingFiles.map(f => f.fileId)); - - const addFilesFromMessage = (message: WorkflowMessage, messageId: string) => { - const documents = (message as any).documents as MessageDocument[] | undefined; - const files = (message as any).files as any[] | undefined; - - if (documents && Array.isArray(documents)) { - documents.forEach((doc: MessageDocument) => { - if (!doc.fileId || doc.fileId.trim() === '') return; - if (!fileMap.has(doc.fileId)) { - const source = pendingFileIds.has(doc.fileId) ? 'user_uploaded' : 'ai_created'; - fileMap.set(doc.fileId, { - id: doc.id || doc.fileId, - fileId: doc.fileId, - fileName: doc.fileName || 'Unknown File', - fileSize: doc.fileSize || 0, - mimeType: doc.mimeType || 'application/octet-stream', - messageId: doc.messageId || messageId, - source - }); - } - }); - } - - if (files && Array.isArray(files)) { - files.forEach((file: any) => { - const fileId = file.id || file.fileId; - if (!fileId || fileId.trim() === '') return; - if (!fileMap.has(fileId)) { - const source = pendingFileIds.has(fileId) ? 'user_uploaded' : 'ai_created'; - fileMap.set(fileId, { - id: fileId, - fileId: fileId, - fileName: file.fileName || file.name || 'Unknown File', - fileSize: file.fileSize || file.size || 0, - mimeType: file.mimeType || file.mime_type || 'application/octet-stream', - messageId: messageId, - source - }); - } - }); - } - }; - - if (messages && messages.length > 0) { - messages.forEach((message: WorkflowMessage) => { - addFilesFromMessage(message, message.id); - }); - } - - if (optimisticMessage) { - addFilesFromMessage(optimisticMessage, optimisticMessage.id || 'optimistic'); - } - - return Array.from(fileMap.values()); - }, [messages, pendingFiles, optimisticMessage]); - - useEffect(() => { - if (!messages || messages.length === 0) return; - if (!optimisticMessage) return; - - // Clear optimistic message when backend's "first" user message arrives via polling. - // The backend message contains the normalizedRequest (which differs from the original prompt), - // so we match by status="first" instead of content comparison. - const hasFirstMessage = messages.some((msg: WorkflowMessage) => - (msg as any).status === 'first' && msg.role?.toLowerCase() === 'user' - ); - - if (hasFirstMessage) { - setOptimisticMessage(null); - } - }, [messages, optimisticMessage]); - - const displayMessages = useMemo(() => { - const processedMessages = (messages || []) - // Filter out locally deleted messages - .filter((message: WorkflowMessage) => !deletedMessageIds.has(message.id)) - .map((message: WorkflowMessage) => { - const files = (message as any).files as any[] | undefined; - const documents = (message as any).documents as MessageDocument[] | undefined; - - let processedDocs = documents; - if (files && Array.isArray(files) && (!documents || documents.length === 0)) { - processedDocs = convertFilesToDocuments(files, message.id); - } - - // Filter out locally deleted documents - if (processedDocs && deletedDocumentFileIds.size > 0) { - processedDocs = processedDocs.filter(doc => !deletedDocumentFileIds.has(doc.fileId)); - } - - return { - ...message, - documents: processedDocs - }; - }); - - // If optimistic message is still active (backend "first" message not yet polled), - // show the optimistic message instead of any backend user messages to avoid duplicates. - const allMessages = [...processedMessages]; - if (optimisticMessage) { - // Find backend "first" user message to inherit its timestamp for correct ordering - const firstBackendMsg = processedMessages.find((msg: WorkflowMessage) => - (msg as any).status === 'first' && msg.role?.toLowerCase() === 'user' - ); - if (!firstBackendMsg) { - // Backend "first" message not yet arrived - show optimistic message - allMessages.push({ ...optimisticMessage, documents: (optimisticMessage as any).documents }); - } - // If firstBackendMsg exists, the useEffect above will clear optimistic on next render - } - - return allMessages.sort(sortMessages); - }, [messages, optimisticMessage, workflowId, deletedDocumentFileIds, deletedMessageIds]); - - const handleFileUpload = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => { - const result = await fileContext.handleFileUpload(file, workflowId || undefined); - - if (result.success && result.fileData) { - const responseData = result.fileData; - const fileData = responseData.file || responseData; - const fileId = fileData?.id; - - if (fileId) { - const newFile: WorkflowFile = { - id: fileId, - fileId: fileId, - fileName: fileData.fileName || file.name, - fileSize: fileData.fileSize || file.size, - mimeType: fileData.mimeType || file.type || 'application/octet-stream', - source: 'user_uploaded' - }; - - setPendingFiles(prev => { - if (prev.some(f => f.fileId === fileId)) { - return prev; - } - return [...prev, newFile]; - }); - } - } - - return { - success: result.success || false, - data: result.fileData || null - }; - }, [workflowId, fileContext]); - - const handleFileAttach = useCallback(async (fileId: string): Promise => { - const isInPending = pendingFiles.some(f => f.fileId === fileId); - - if (isInPending) { - setPendingFiles(prev => prev.filter(f => f.fileId !== fileId)); - } else { - let workflowFile: WorkflowFile | null = null; - - const userFile = fileContext.files.find(f => f.id === fileId); - if (userFile) { - workflowFile = { - id: userFile.id, - fileId: userFile.id, - fileName: userFile.file_name, - fileSize: userFile.size || 0, - mimeType: userFile.mime_type || 'application/octet-stream', - source: 'user_uploaded' - }; - } else { - const existingWorkflowFile = workflowFiles.find(f => f.fileId === fileId); - if (existingWorkflowFile) { - workflowFile = { - ...existingWorkflowFile, - id: existingWorkflowFile.id || existingWorkflowFile.fileId, - fileId: existingWorkflowFile.fileId, - fileName: existingWorkflowFile.fileName || 'Unknown File', - fileSize: existingWorkflowFile.fileSize || 0, - mimeType: existingWorkflowFile.mimeType || 'application/octet-stream', - source: existingWorkflowFile.source || 'user_uploaded' - }; - } - } - - if (workflowFile) { - setPendingFiles(prev => { - if (prev.some(f => f.fileId === fileId)) { - return prev; - } - return [...prev, workflowFile!]; - }); - } - } - }, [pendingFiles, fileContext.files, workflowFiles]); - - const handleFileUploadAndAttach = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => { - return await handleFileUpload(file); - }, [handleFileUpload]); - - const handleFileRemove = useCallback(async (file: WorkflowFile) => { - setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId)); - }, []); - - const handleFileDelete = useCallback(async (file: WorkflowFile) => { - if (!file.fileId) return; - - // Immediately remove document from UI for instant feedback - setDeletedDocumentFileIds(prev => new Set([...prev, file.fileId])); - - if (workflowId && file.messageId) { - // Document in a message: only remove the ChatDocument reference, keep the file itself - try { - await deleteFileFromMessageApi(request, workflowId, file.messageId, file.fileId); - } catch (error) { - // Restore document in UI on failure - setDeletedDocumentFileIds(prev => { - const next = new Set(prev); - next.delete(file.fileId); - return next; - }); - } - } else { - // Standalone file (pending file not yet in a message): delete the actual file - const success = await fileContext.handleFileDelete(file.fileId, () => { - setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId)); - }); - - if (success) { - setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId)); - } else { - // Restore document in UI on failure - setDeletedDocumentFileIds(prev => { - const next = new Set(prev); - next.delete(file.fileId); - return next; - }); - } - } - }, [workflowId, fileContext, request]); - - // handleFileView is a no-op because ViewActionButton's ContentPreview handles the preview internally - const handleFileView = useCallback(async (_file: WorkflowFile) => { - // The ViewActionButton component handles the preview via ContentPreview - // No additional action needed here - }, []); - - const handleFileDownload = useCallback(async (file: WorkflowFile) => { - if (!file.fileId) return; - await fileContext.handleFileDownload(file.fileId, file.fileName); - }, [fileContext]); - - const handleMessageDelete = useCallback(async (messageId: string) => { - if (!workflowId || !messageId) return; - - // Immediately remove message from UI for instant feedback - setDeletedMessageIds(prev => new Set([...prev, messageId])); - setDeletingMessages(prev => new Set([...prev, messageId])); - - try { - await deleteMessageApi(request, workflowId, messageId); - } catch (error: any) { - // Restore message in UI on failure - setDeletedMessageIds(prev => { - const next = new Set(prev); - next.delete(messageId); - return next; - }); - console.error('Failed to delete message:', error); - } finally { - setDeletingMessages(prev => { - const next = new Set(prev); - next.delete(messageId); - return next; - }); - } - }, [workflowId, request]); - - const onInputChange = useCallback((value: string) => { - setInputValue(value); - }, []); - - // Separate stop handler - only stops the workflow without sending new input - const handleStop = useCallback(async () => { - if (!workflowId) return { success: false, error: 'No workflow to stop' }; - - try { - const result = await stopWorkflow(); - return result; - } catch (error: any) { - return { success: false, error: error.message || 'Failed to stop workflow' }; - } - }, [workflowId, stopWorkflow]); - - const handleSubmit = useCallback(async () => { - const trimmedInput = inputValue.trim(); - - // If running and no new input, just stop - if (isRunning && workflowId && !trimmedInput) { - try { - await stopWorkflow(); - } catch (error) { - // Ignore stop errors - } - return; - } - - // If running with new input, stop first then continue with new input - if (isRunning && workflowId && trimmedInput) { - try { - // Stop the current workflow - await stopWorkflow(); - // Continue below to send new input - } catch (error) { - // Ignore stop errors, try to continue anyway - } - } - - // No input and not running = nothing to do - if (!trimmedInput || startingWorkflow) { - return; - } - if (!trimmedInput || startingWorkflow) { - return; - } - - try { - const filesToSend = pendingFiles.filter(file => file.fileId); - const fileIdsToSend = filesToSend.map(f => f.fileId).filter((id): id is string => !!id); - const sentFileIdsSet = new Set(fileIdsToSend); - - // Optimistically render user message immediately - const optimisticMsg: WorkflowMessage = { - id: `optimistic-${Date.now()}`, - workflowId: workflowId || '', - message: trimmedInput, - role: 'user', - publishedAt: Date.now(), - documents: filesToSend.map(file => ({ - id: file.id || file.fileId, - fileId: file.fileId, - fileName: file.fileName, - fileSize: file.fileSize, - mimeType: file.mimeType, - messageId: `optimistic-${Date.now()}`, - roundNumber: 0, - taskNumber: 0, - actionNumber: 0, - actionId: '' - })) - }; - setOptimisticMessage(optimisticMsg); - - // Optimistically update workflow status to 'running' immediately - if (setWorkflowStatusOptimistic) { - setWorkflowStatusOptimistic('running'); - } - - setPendingFiles(prev => prev.filter(file => - !file.fileId || !sentFileIdsSet.has(file.fileId) - )); - - if (!chatWorkflowPermission || chatWorkflowPermission.create === 'n') { - setOptimisticMessage(null); - if (setWorkflowStatusOptimistic) { - setWorkflowStatusOptimistic('idle'); - } - return; - } - - const selectedMode = workflowMode || 'Dynamic'; - const apiWorkflowMode: 'Dynamic' | 'Automation' = selectedMode; - - const workflowOptions: { workflowId?: string; workflowMode: 'Dynamic' | 'Automation' } = { - workflowMode: apiWorkflowMode - }; - - if (workflowId) { - workflowOptions.workflowId = workflowId; - } - - const requestBody = { - prompt: trimmedInput, - listFileId: fileIdsToSend.length > 0 ? fileIdsToSend : undefined, - userLanguage: 'en', - allowedProviders: selectedProviders.length > 0 ? selectedProviders : undefined // AI provider filter (multiselect) - }; - - // Debug: Log provider selection - console.log('🤖 Provider selection:', { selectedProviders, sentProviders: requestBody.allowedProviders }); - - const result = await startWorkflow(requestBody, workflowOptions); - - if (result.success) { - setInputValue(''); - - const wasNewWorkflow = !workflowId; - if (wasNewWorkflow && result.data) { - const workflow = result.data as Workflow; - - // Dispatch event first to trigger refetch in useWorkflows - window.dispatchEvent(new CustomEvent('workflowCreated', { - detail: { workflow } - })); - - // Refetch workflows list to ensure dropdown is updated - await refetchWorkflows(); - - // Update context first (this will trigger the sync effect to update lifecycle) - selectWorkflowFromContext(workflow.id); - - // Also directly update lifecycle to ensure immediate state update - await selectWorkflow(workflow.id); - } else if (workflowId) { - // For resumed workflows, ensure context is synced and update lifecycle - selectWorkflowFromContext(workflowId); - await selectWorkflow(workflowId); - } - } else { - setOptimisticMessage(null); - if (setWorkflowStatusOptimistic) { - setWorkflowStatusOptimistic('idle'); - } - } - } catch (error) { - setOptimisticMessage(null); - if (setWorkflowStatusOptimistic) { - setWorkflowStatusOptimistic('idle'); - } - } - }, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, selectedProviders, setWorkflowStatusOptimistic]); - - useEffect(() => { - const handleWorkflowCleared = () => { - // Reset all workflow-related state - setPendingFiles([]); - setOptimisticMessage(null); - // Reset workflow lifecycle state - resetWorkflow(); - // NOTE: Do NOT call clearWorkflowFromContext() here — this handler is - // triggered BY clearWorkflow() which already set the context to null. - // Calling it again would dispatch another 'workflowCleared' event → infinite recursion. - }; - - window.addEventListener('workflowCleared', handleWorkflowCleared); - return () => { - window.removeEventListener('workflowCleared', handleWorkflowCleared); - }; - }, [resetWorkflow]); - - const handleWorkflowSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record } | null) => { - if (item === null) { - clearWorkflowFromContext(); - resetWorkflow(); - setPendingFiles([]); - setOptimisticMessage(null); - return; - } - - const workflowIdToSelect = typeof item.id === 'string' ? item.id : String(item.id); - selectWorkflowFromContext(workflowIdToSelect); - - if (selectWorkflow) { - await selectWorkflow(workflowIdToSelect); - } - }, [selectWorkflow, resetWorkflow, selectWorkflowFromContext, clearWorkflowFromContext]); - - const handlePromptSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record } | null) => { - if (item === null) { - setSelectedPromptId(null); - return; - } - - const promptId = typeof item.id === 'string' ? item.id : String(item.id); - - if (!promptPermission || promptPermission.read === 'n') { - return; - } - - try { - const prompt = await fetchPromptById(promptId); - if (prompt && prompt.content) { - setSelectedPromptId(promptId); - setInputValue(prompt.content); - } - } catch (error: any) { - } - }, [fetchPromptById, promptPermission]); - - const handleWorkflowModeSelect = useCallback((item: { id: string | number; label: string; value: any; metadata?: Record } | null) => { - if (item === null) { - setWorkflowMode(null); - return; - } - - const modeValue = item.value || item.id; - const modeString = typeof modeValue === 'string' ? modeValue : String(modeValue); - - if (modeString === 'Dynamic' || modeString === 'Automation') { - const mode = modeString as 'Dynamic' | 'Automation'; - setWorkflowMode(mode); - } - }, []); - - const workflowItems = useMemo(() => { - console.log('🔄 useDashboardInputForm: Computing workflowItems from workflows:', workflows); - - if (!workflows || !Array.isArray(workflows)) { - console.warn('⚠️ useDashboardInputForm: workflows is not an array:', workflows); - return []; - } - - if (workflows.length === 0) { - console.log('ℹ️ useDashboardInputForm: workflows array is empty'); - return []; - } - - const items = workflows.map(workflow => ({ - id: workflow.id, - label: workflow.name || workflow.id, - value: workflow, - metadata: { - status: workflow.status, - workflowMode: workflow.workflowMode - } - })); - - console.log(`✅ useDashboardInputForm: Created ${items.length} workflow items:`, items); - return items; - }, [workflows]); - - const promptItems = useMemo(() => { - if (!promptPermission || promptPermission.view === false || promptPermission.read === 'n') { - return []; - } - return prompts.map(prompt => ({ - id: prompt.id, - label: prompt.name || prompt.id, - value: prompt, - metadata: { - content: prompt.content - } - })); - }, [prompts, promptPermission]); - - const workflowModeItems = useMemo(() => [ - { - id: 'Automation', - label: 'Automation', - value: 'Automation' as const, - metadata: { - description: 'Automated workflow processing' - } - }, - { - id: 'Dynamic', - label: 'Dynamic', - value: 'Dynamic' as const, - metadata: { - description: 'Iterative dynamic-style processing' - } - } - ], []); - - return { - data: [], - loading: false, - error: null, - inputValue, - onInputChange, - handleSubmit, - handleStop, - isSubmitting: startingWorkflow || isStopping, - isStopping, - workflowId: workflowId || undefined, - workflowStatus, - currentRound, - isRunning, - messages: displayMessages || [], - logs: unifiedContentLogs || [], // Unified content logs (without operationId) - dashboardTree, // Dashboard log tree (logs with operationId) - onToggleOperationExpanded: toggleOperationExpanded, - onToggleRoundExpanded: toggleRoundExpanded, - getChildOperations, - workflowItems, - selectedWorkflowId: workflowId || selectedWorkflowId || null, - onWorkflowSelect: handleWorkflowSelect, - workflowsLoading, - promptItems, - selectedPromptId, - onPromptSelect: handlePromptSelect, - promptsLoading, - promptPermission, - workflowModeItems, - selectedWorkflowMode: workflowMode, - onWorkflowModeSelect: handleWorkflowModeSelect, - playgroundUIPermission, - chatWorkflowPermission, - filePermission, - workflowFiles, - pendingFiles, - handleFileUpload, - handleFileDelete, - handleFileRemove, - handleFileView, - uploadingFile: fileContext.uploadingFile, - deletingFiles: fileContext.deletingFiles, - previewingFiles: fileContext.previewingFiles, - downloadingFiles: fileContext.downloadingFiles, - handleFileDownload, - handleMessageDelete, - deletingMessages, - isFileAttachmentPopupOpen, - setIsFileAttachmentPopupOpen, - allUserFiles: fileContext.files || [], - handleFileAttach, - handleFileUploadAndAttach, - latestStats, - // AI Provider selection (multiselect) - selectedProviders, - onProvidersChange: setSelectedProviders - }; -} - -export function createDashboardHook(instanceId: string) { - return () => useDashboardInputForm(instanceId); -} - diff --git a/src/hooks/playground/useDashboardLogTree.ts b/src/hooks/playground/useDashboardLogTree.ts deleted file mode 100644 index 31258dc..0000000 --- a/src/hooks/playground/useDashboardLogTree.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { useState, useCallback, useRef } from 'react'; -import { WorkflowLog } from '../../components/UiComponents/Log/LogTypes'; - -interface OperationData { - logs: Map; - parentId: string | null; - expanded: boolean; - latestProgress: number | null; - latestStatus: string | null; - operationName: string | null; // Stable name from first log - latestMessage: string | null; // Latest status message that updates - roundNumber: number | null; // Track which round this operation belongs to -} - -interface RoundData { - operations: Map; - rootOperations: string[]; - expanded: boolean; - isCompleted: boolean; -} - -interface DashboardLogTree { - operations: Map; - rootOperations: string[]; - logExpandedStates: Map; - currentRound: number | null; - rounds: Map; -} - -export function useDashboardLogTree() { - const [tree, setTree] = useState({ - operations: new Map(), - rootOperations: [], - logExpandedStates: new Map(), - currentRound: null, - rounds: new Map() - }); - - const treeRef = useRef(tree); - treeRef.current = tree; - - const generateLogId = useCallback((log: WorkflowLog): string => { - if (log.id) { - return log.id; - } - return `log_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - }, []); - - const processDashboardLogs = useCallback((logs: WorkflowLog[]) => { - setTree(prevTree => { - const newTree: DashboardLogTree = { - operations: new Map(prevTree.operations), - rootOperations: [...prevTree.rootOperations], - logExpandedStates: new Map(prevTree.logExpandedStates), - currentRound: prevTree.currentRound, - rounds: new Map(prevTree.rounds) - }; - - // Process each log - logs.forEach(log => { - if (!log.operationId) { - return; // Skip logs without operationId - } - - const operationId = log.operationId; - const logId = generateLogId(log); - const logRoundNumber = (log as any).roundNumber as number | null | undefined; - - // Update current round tracking - if (logRoundNumber !== null && logRoundNumber !== undefined) { - if (newTree.currentRound === null || logRoundNumber > newTree.currentRound) { - newTree.currentRound = logRoundNumber; - } - } - - // Get or create operation - const existingOperation = newTree.operations.get(operationId); - - // Create new logs Map (copy existing logs if updating) - const logsMap = existingOperation - ? new Map(existingOperation.logs) - : new Map(); - - // Store log (Map ensures uniqueness by logId) - logsMap.set(logId, log); - - // Determine stable operation name (only set once, never change) - // Always use formatted operationId as the stable name - don't use log messages - // Log messages are status updates and should go in latestMessage, not operationName - let operationName = existingOperation?.operationName || null; - if (operationName === null) { - // Remove UUIDs and timestamps from operationId before formatting - // UUID pattern: 8-4-4-4-12 hex digits (e.g., "1e6d7b14-4f30-40e2-b7a6-748b63b6a7f5") - // Also remove standalone long hex strings that might be timestamps or IDs - let cleanedId = operationId - .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '') // Remove UUIDs - .replace(/\b[0-9a-f]{32,}\b/gi, '') // Remove long hex strings (timestamps/IDs) - .replace(/\s+/g, ' ') // Normalize whitespace - .trim(); - - // Format by splitting on dashes/underscores and capitalizing - // This creates a stable, readable name like "Workflow Planning" from "workflow-planning" - const formattedName = cleanedId - .split(/[-_\s]+/) - .filter(word => word.length > 0) // Remove empty strings - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' '); - operationName = formattedName || operationId.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '').trim(); - } - - // Update latest message (for status tag) - this updates with each poll - const latestMessage = log.message || existingOperation?.latestMessage || null; - - // Update parentId if not set yet (from first log entry) - const parentId = existingOperation?.parentId !== null && existingOperation?.parentId !== undefined - ? existingOperation.parentId - : (log.parentId !== undefined && log.parentId !== null ? log.parentId : null); - - // Update latest progress (use latest value) - const latestProgress = log.progress !== undefined && log.progress !== null - ? log.progress - : existingOperation?.latestProgress ?? null; - - // Update latest status (use latest value) - const latestStatus = log.status !== undefined && log.status !== null - ? log.status - : existingOperation?.latestStatus ?? null; - - // Get round number for this operation (from log or existing) - const roundNumber = logRoundNumber !== null && logRoundNumber !== undefined - ? logRoundNumber - : existingOperation?.roundNumber ?? null; - - // Create new operation object to ensure React detects the change - const operation: OperationData = { - logs: logsMap, - parentId, - expanded: existingOperation?.expanded ?? false, - latestProgress, - latestStatus, - operationName, - latestMessage, - roundNumber - }; - - newTree.operations.set(operationId, operation); - - // Add operation to its round - if (roundNumber !== null) { - if (!newTree.rounds.has(roundNumber)) { - newTree.rounds.set(roundNumber, { - operations: new Map(), - rootOperations: [], - expanded: true, // New rounds start expanded - isCompleted: false - }); - } - const round = newTree.rounds.get(roundNumber)!; - round.operations.set(operationId, operation); - } - }); - - // Rebuild root operations list per round - newTree.rounds.forEach((round, roundNumber) => { - const rootOpsSet = new Set(); - round.operations.forEach((op, opId) => { - if (op.parentId === null) { - rootOpsSet.add(opId); - } else { - // Check if parent is in a different round - then this is a root in THIS round - const parentOp = newTree.operations.get(op.parentId); - if (!parentOp || parentOp.roundNumber !== roundNumber) { - rootOpsSet.add(opId); - } - } - }); - - // Sort by timestamp - round.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => { - const opA = round.operations.get(opIdA); - const opB = round.operations.get(opIdB); - if (!opA || !opB) return 0; - - const logsA = Array.from(opA.logs.values()); - const logsB = Array.from(opB.logs.values()); - - if (logsA.length === 0 && logsB.length === 0) return 0; - if (logsA.length === 0) return 1; - if (logsB.length === 0) return -1; - - const earliestA = Math.min(...logsA.map(log => log.timestamp || 0)); - const earliestB = Math.min(...logsB.map(log => log.timestamp || 0)); - - return earliestA - earliestB; - }); - - // Update completion status - const allOpsCompleted = Array.from(round.operations.values()).every(op => - op.latestStatus === 'completed' || op.latestStatus === 'success' - ); - round.isCompleted = allOpsCompleted; - - // Auto-collapse completed rounds (except current) - if (round.isCompleted && roundNumber !== newTree.currentRound) { - round.expanded = false; - } - }); - - // Rebuild global root operations list (operations without parentId) - const rootOpsSet = new Set(); - newTree.operations.forEach((op, opId) => { - if (op.parentId === null) { - rootOpsSet.add(opId); - } - }); - // Sort by timestamp of earliest log entry (chronological order) - newTree.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => { - const opA = newTree.operations.get(opIdA); - const opB = newTree.operations.get(opIdB); - if (!opA || !opB) return 0; - - const logsA = Array.from(opA.logs.values()); - const logsB = Array.from(opB.logs.values()); - - if (logsA.length === 0 && logsB.length === 0) return 0; - if (logsA.length === 0) return 1; - if (logsB.length === 0) return -1; - - const earliestA = Math.min(...logsA.map(log => log.timestamp || 0)); - const earliestB = Math.min(...logsB.map(log => log.timestamp || 0)); - - return earliestA - earliestB; - }); - - return newTree; - }); - }, [generateLogId]); - - const clearDashboard = useCallback((resetRound: boolean = false) => { - setTree({ - operations: new Map(), - rootOperations: [], - logExpandedStates: new Map(), - currentRound: resetRound ? null : treeRef.current.currentRound, - rounds: new Map() - }); - }, []); - - const toggleOperationExpanded = useCallback((operationId: string) => { - setTree(prevTree => { - const operation = prevTree.operations.get(operationId); - if (!operation) { - return prevTree; - } - - const newTree: DashboardLogTree = { - ...prevTree, - operations: new Map(prevTree.operations) - }; - - const updatedOperation = { - ...operation, - expanded: !operation.expanded - }; - - newTree.operations.set(operationId, updatedOperation); - - return newTree; - }); - }, []); - - const updateCurrentRound = useCallback((round: number | null) => { - setTree(prevTree => { - // Only update current round, keep all rounds data - // Auto-collapse previous rounds when new round starts - if (prevTree.currentRound !== null && round !== null && prevTree.currentRound !== round) { - const newRounds = new Map(prevTree.rounds); - - // Collapse the old current round - const oldRound = newRounds.get(prevTree.currentRound); - if (oldRound) { - newRounds.set(prevTree.currentRound, { - ...oldRound, - expanded: false - }); - } - - return { - ...prevTree, - currentRound: round, - rounds: newRounds - }; - } - - return { - ...prevTree, - currentRound: round - }; - }); - }, []); - - const toggleRoundExpanded = useCallback((roundNumber: number) => { - setTree(prevTree => { - const round = prevTree.rounds.get(roundNumber); - if (!round) { - return prevTree; - } - - const newRounds = new Map(prevTree.rounds); - newRounds.set(roundNumber, { - ...round, - expanded: !round.expanded - }); - - return { - ...prevTree, - rounds: newRounds - }; - }); - }, []); - - const getChildOperations = useCallback((parentId: string | null): string[] => { - const currentTree = treeRef.current; - const childOps = Array.from(currentTree.operations.entries()) - .filter(([_, op]) => op.parentId === parentId) - .map(([opId, op]) => ({ opId, op })); - - // Sort by timestamp of earliest log entry (chronological order) - return childOps.sort((a, b) => { - const logsA = Array.from(a.op.logs.values()); - const logsB = Array.from(b.op.logs.values()); - - if (logsA.length === 0 && logsB.length === 0) return 0; - if (logsA.length === 0) return 1; // Put operations without logs at the end - if (logsB.length === 0) return -1; - - const earliestA = Math.min(...logsA.map(log => log.timestamp || 0)); - const earliestB = Math.min(...logsB.map(log => log.timestamp || 0)); - - return earliestA - earliestB; // Ascending order (oldest first) - }).map(({ opId }) => opId); - }, []); - - return { - tree, - processDashboardLogs, - clearDashboard, - toggleOperationExpanded, - toggleRoundExpanded, - updateCurrentRound, - getChildOperations - }; -} - diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts deleted file mode 100644 index 085d6f7..0000000 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { useApiRequest } from '../useApi'; -import { - type Workflow, - type WorkflowMessage, - type WorkflowLog, - type StartWorkflowRequest, - fetchWorkflow as fetchWorkflowApi, - fetchChatData -} from '../../api/workflowApi'; -import { useWorkflowOperations } from './useWorkflowOperations'; -import { sortMessages, sortLogs } from './playgroundUtils'; -import { useWorkflowPolling } from './useWorkflowPolling'; -import { getWorkflowApiBaseUrl } from '../useWorkflows'; - -interface UnifiedChatDataItem { - type: 'message' | 'log'; - item: WorkflowMessage | WorkflowLog; - createdAt: number; -} - -/** - * ============================================================================= - * WORKFLOW LIFECYCLE STATE MACHINE - * ============================================================================= - * - * WORKFLOW STATUS (from Backend): - * • idle - No workflow - * • running - Workflow is processing - * • completed - Round finished (Backend processed "last" message) - * • stopped - User stopped the workflow - * • failed - Error occurred - * - * UI FLAG: - * • hasRenderedLastMessage: boolean - * - true: "last" message was rendered in UI - * - false: "last" message not yet in UI - * - * POLLING LOGIC: - * POLL ACTIVE when: - * status === 'running' - * OR (status === 'completed' AND !hasRenderedLastMessage) - * - * POLL STOPS when: - * status === 'stopped' - * OR status === 'failed' - * OR hasRenderedLastMessage === true - * - * TRANSITIONS: - * [Send Button] (from any status): - * → hasRenderedLastMessage = false (new round starts) - * → afterTimestamp = now - * → Start polling - * - * [Load Workflow]: - * → Load all data - * → Check if last message has status="last" - * → If yes: hasRenderedLastMessage = true, no polling - * → If no AND status=running: Start polling - * - * [Message with status="last" rendered]: - * → hasRenderedLastMessage = true - * → Stop polling - * - * ============================================================================= - */ - -export function useWorkflowLifecycle(instanceId: string) { - const apiBaseUrl = useMemo(() => getWorkflowApiBaseUrl(instanceId, 'chatplayground'), [instanceId]); - - // === STATE === - const [workflowId, setWorkflowId] = useState(null); - const [workflowStatus, setWorkflowStatus] = useState('idle'); - const [currentRound, setCurrentRound] = useState(undefined); - const [messages, setMessages] = useState([]); - const [logs, setLogs] = useState([]); - const [dashboardLogs, setDashboardLogs] = useState([]); - const [unifiedContentLogs, setUnifiedContentLogs] = useState([]); - const [latestStats, setLatestStats] = useState<{ priceCHF?: number } | null>(null); - - // === REFS FOR SYNC ACCESS === - const statusRef = useRef('idle'); - const lastRenderedTimestampRef = useRef(null); - - // === KEY STATE MACHINE FLAG === - // This flag tracks if the UI has rendered a message with status="last" - // Polling continues until this is true (even if backend status is "completed") - const hasRenderedLastMessageRef = useRef(false); - const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState(false); - - // Flag to prevent useEffect from stopping polling during active workflow start - const isStartingWorkflowRef = useRef(false); - - // === HOOKS === - const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations(); - const { request } = useApiRequest(); - const pollingController = useWorkflowPolling(); - const pollingControllerRef = useRef(pollingController); - pollingControllerRef.current = pollingController; - - // === HELPER: Update workflow status === - const updateWorkflowStatus = useCallback((newStatus: string) => { - statusRef.current = newStatus; - setWorkflowStatus(newStatus); - console.log('📍 Status updated to:', newStatus); - }, []); - - // === HELPER: Convert backend log format to frontend format === - const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => { - return { - id: log.id, - workflowId: log.workflowId || workflowId || '', - message: log.message || '', - type: log.type || 'info', - timestamp: log.timestamp || log.createdAt || Date.now(), - status: log.status || 'running', - progress: log.progress !== undefined && log.progress !== null ? log.progress : undefined, - performance: log.performance, - operationId: log.operationId || null, - parentId: log.parentId || null - }; - }, [workflowId]); - - // === CORE: Process unified chat data === - const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; workflowCost: number }) => { - console.log('🔄 Processing chat data:', { - messages: chatData.messages?.length || 0, - logs: chatData.logs?.length || 0, - workflowCost: chatData.workflowCost ?? 0 - }); - - const timeline: UnifiedChatDataItem[] = []; - - (chatData.messages || []).forEach((message: WorkflowMessage) => { - timeline.push({ - type: 'message', - item: message, - createdAt: message.publishedAt || message.timestamp || Date.now() - }); - }); - - (chatData.logs || []).forEach((log: any) => { - timeline.push({ - type: 'log', - item: log, - createdAt: log.timestamp || log.createdAt || Date.now() - }); - }); - - timeline.sort((a, b) => a.createdAt - b.createdAt); - - // Update lastRenderedTimestamp - if (timeline.length > 0) { - lastRenderedTimestampRef.current = timeline[timeline.length - 1].createdAt; - } - - // === CHECK FOR "LAST" MESSAGE === - // This is the key state machine logic: detect when a "last" message arrives - let foundLastMessage = false; - - timeline.forEach((item) => { - if (item.type === 'message') { - const message = item.item as WorkflowMessage; - if ((message as any).status === 'last') { - foundLastMessage = true; - console.log('🏁 Found "last" message:', message.id); - } - } - }); - - // === STATE MACHINE: Handle "last" message === - if (foundLastMessage && !hasRenderedLastMessageRef.current) { - console.log('🛑 "last" message detected - stopping polling'); - hasRenderedLastMessageRef.current = true; - setHasRenderedLastMessage(true); - pollingControllerRef.current.stopPolling(); - } - - // === UPDATE MESSAGES STATE === - setMessages(prevMessages => { - const newMessages: WorkflowMessage[] = [...prevMessages]; - let hasChanges = false; - - timeline.forEach((item) => { - if (item.type === 'message') { - const message = item.item as WorkflowMessage; - if (!message || !message.id) return; - - const existingIndex = newMessages.findIndex(m => m.id === message.id); - if (existingIndex >= 0) { - newMessages[existingIndex] = message; - hasChanges = true; - } else { - newMessages.push(message); - hasChanges = true; - } - } - }); - - if (hasChanges || timeline.some(item => item.type === 'message')) { - return [...newMessages].sort(sortMessages); - } - return prevMessages; - }); - - // === UPDATE DASHBOARD LOGS (with operationId) === - setDashboardLogs(prevLogs => { - const newLogs: WorkflowLog[] = [...prevLogs]; - let hasChanges = false; - - timeline.forEach((item) => { - if (item.type === 'log') { - const frontendLog = convertLogToFrontendFormat(item.item); - if (frontendLog.operationId) { - const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id); - if (existingIndex >= 0) { - if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) { - newLogs[existingIndex] = frontendLog; - hasChanges = true; - } - } else { - newLogs.push(frontendLog); - hasChanges = true; - } - } - } - }); - - return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs; - }); - - // === UPDATE UNIFIED CONTENT LOGS (without operationId) === - setUnifiedContentLogs(prevLogs => { - const newLogs: WorkflowLog[] = [...prevLogs]; - let hasChanges = false; - - timeline.forEach((item) => { - if (item.type === 'log') { - const frontendLog = convertLogToFrontendFormat(item.item); - if (!frontendLog.operationId) { - const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id); - if (existingIndex >= 0) { - if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) { - newLogs[existingIndex] = frontendLog; - hasChanges = true; - } - } else { - newLogs.push(frontendLog); - hasChanges = true; - } - } - } - }); - - return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs; - }); - - // === UPDATE COMBINED LOGS === - setLogs(prevLogs => { - const allLogs: WorkflowLog[] = [...prevLogs]; - - timeline.forEach((item) => { - if (item.type === 'log') { - const frontendLog = convertLogToFrontendFormat(item.item); - const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id); - if (existingIndex >= 0) { - allLogs[existingIndex] = frontendLog; - } else { - allLogs.push(frontendLog); - } - } - }); - - return [...allLogs].sort(sortLogs); - }); - - // === UPDATE COST from billing transactions (single source of truth) === - const cost = chatData.workflowCost ?? 0; - setLatestStats(cost > 0 ? { priceCHF: cost } : null); - }, [convertLogToFrontendFormat]); - - // === POLLING FUNCTION === - const pollWorkflowData = useCallback(async (id: string) => { - try { - const afterTimestamp = lastRenderedTimestampRef.current || undefined; - - // Fetch workflow status - const workflowData = await fetchWorkflowApi(request, id, apiBaseUrl).catch(() => null); - - if (workflowData) { - const status = workflowData.status || 'idle'; - const round = workflowData.currentRound; - - updateWorkflowStatus(status); - if (round !== undefined) setCurrentRound(round); - - // === STATE MACHINE: Check if polling should stop based on status === - if (status === 'stopped' || status === 'failed') { - console.log(`🛑 Workflow ${status} - stopping polling immediately`); - pollingControllerRef.current.stopPolling(); - return; - } - } - - // Fetch chat data - const chatData = await fetchChatData(request, instanceId, id, afterTimestamp); - - console.log('📊 Polled chat data:', { - messages: chatData.messages?.length || 0, - logs: chatData.logs?.length || 0, - workflowCost: chatData.workflowCost ?? 0, - afterTimestamp - }); - - // Process data (this will detect "last" message and stop polling if found) - processUnifiedChatData(chatData); - - } catch (error) { - console.error('❌ Polling error:', error); - } - }, [request, instanceId, apiBaseUrl, updateWorkflowStatus, processUnifiedChatData]); - - // === POLLING CONTROL EFFECT === - useEffect(() => { - if (!workflowId) { - pollingControllerRef.current.stopPolling(); - return; - } - - // Skip if we're actively starting a workflow - handleStartWorkflow manages polling - if (isStartingWorkflowRef.current) { - console.log('📍 Polling decision: Skipping - workflow start in progress'); - return; - } - - // === STATE MACHINE: Determine if polling should be active === - // Use ref for immediate value (state may be stale) - const hasLastMessage = hasRenderedLastMessageRef.current; - - const shouldPoll = - workflowStatus === 'running' || - (workflowStatus === 'completed' && !hasLastMessage); - - const shouldStopImmediately = - workflowStatus === 'stopped' || - workflowStatus === 'failed' || - hasLastMessage; - - console.log('📍 Polling decision:', { - workflowStatus, - hasRenderedLastMessage: hasLastMessage, - shouldPoll, - shouldStopImmediately - }); - - if (shouldPoll) { - pollingControllerRef.current.startPolling(workflowId, pollWorkflowData); - } else if (shouldStopImmediately) { - pollingControllerRef.current.stopPolling(); - } - - return () => { - pollingControllerRef.current.stopPolling(); - }; - }, [workflowStatus, workflowId, hasRenderedLastMessage, pollWorkflowData]); - - // === START WORKFLOW (Send Button) === - const handleStartWorkflow = useCallback(async ( - workflowData: StartWorkflowRequest, - options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' } - ) => { - try { - // Set flag to prevent useEffect from interfering during start - isStartingWorkflowRef.current = true; - - const result = await startWorkflow(instanceId, workflowData, options); - - if (result.success && result.data) { - const workflow = result.data as Workflow; - - // === STATE MACHINE: New round starts === - console.log('🚀 Starting workflow:', workflow.id); - - // Reset state for new round - MUST update refs BEFORE state - hasRenderedLastMessageRef.current = false; - - // Set afterTimestamp to NOW - only poll for new data - lastRenderedTimestampRef.current = Date.now(); - - // Start polling immediately (before state updates trigger useEffect) - pollingControllerRef.current.startPolling(workflow.id, pollWorkflowData); - - // Now update state (will trigger re-renders) - setWorkflowId(workflow.id); - setHasRenderedLastMessage(false); - updateWorkflowStatus(workflow.status || 'running'); - - // Clear the starting flag after a short delay to allow React to settle - setTimeout(() => { - isStartingWorkflowRef.current = false; - }, 100); - - return { success: true, data: result.data }; - } else { - isStartingWorkflowRef.current = false; - return { success: false, error: result.error || 'Failed to start workflow' }; - } - } catch (error: any) { - isStartingWorkflowRef.current = false; - return { success: false, error: error.message || 'Failed to start workflow' }; - } - }, [instanceId, startWorkflow, updateWorkflowStatus, pollWorkflowData]); - - // === STOP WORKFLOW === - const handleStopWorkflow = useCallback(async () => { - if (!workflowId) { - return { success: false, error: 'No workflow to stop' }; - } - - try { - const result = await stopWorkflow(instanceId, workflowId); - - if (result.success) { - updateWorkflowStatus('stopped'); - pollingControllerRef.current.stopPolling(); - return { success: true }; - } else { - return { success: false, error: result.error || 'Failed to stop workflow' }; - } - } catch (error: any) { - return { success: false, error: error.message || 'Failed to stop workflow' }; - } - }, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]); - - // === RESET WORKFLOW === - const resetWorkflow = useCallback(() => { - console.log('🔄 Resetting workflow state'); - - setWorkflowId(null); - updateWorkflowStatus('idle'); - setCurrentRound(undefined); - setMessages([]); - setLogs([]); - setDashboardLogs([]); - setUnifiedContentLogs([]); - setLatestStats(null); - - lastRenderedTimestampRef.current = null; - hasRenderedLastMessageRef.current = false; - setHasRenderedLastMessage(false); - - pollingControllerRef.current.stopPolling(); - }, [updateWorkflowStatus]); - - // === SELECT/LOAD WORKFLOW === - const selectWorkflow = useCallback(async (workflowIdToSelect: string) => { - try { - console.log('📥 Loading workflow:', workflowIdToSelect); - - setWorkflowId(workflowIdToSelect); - lastRenderedTimestampRef.current = null; - hasRenderedLastMessageRef.current = false; - setHasRenderedLastMessage(false); - setLatestStats(null); - - // Fetch workflow data - const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null); - - if (!workflowData) { - setMessages([]); - setLogs([]); - setDashboardLogs([]); - setUnifiedContentLogs([]); - setLatestStats(null); - updateWorkflowStatus('idle'); - return; - } - - const status = workflowData.status || 'idle'; - const round = workflowData.currentRound; - - updateWorkflowStatus(status); - if (round !== undefined) setCurrentRound(round); - - // Fetch all chat data (no afterTimestamp = get everything) - try { - const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined); - console.log('📥 Loaded chat data:', { - messages: chatData.messages?.length || 0, - logs: chatData.logs?.length || 0, - workflowCost: chatData.workflowCost ?? 0 - }); - - // === STATE MACHINE: Check if last message has status="last" === - const allMessages = chatData.messages || []; - const sortedMessages = [...allMessages].sort((a, b) => { - const aTime = a.publishedAt || a.timestamp || 0; - const bTime = b.publishedAt || b.timestamp || 0; - return bTime - aTime; // Sort descending (newest first) - }); - - const lastMessage = sortedMessages[0]; - const lastMessageStatus = lastMessage ? (lastMessage as any).status : null; - - console.log('📍 Last message status:', lastMessageStatus); - - if (lastMessageStatus === 'last') { - // Round is complete - don't start polling - hasRenderedLastMessageRef.current = true; - setHasRenderedLastMessage(true); - console.log('✅ Workflow round complete - no polling needed'); - } else if (status === 'running') { - // Workflow is running - polling will start via useEffect - console.log('🔄 Workflow is running - polling will start'); - } - - // Process the data - processUnifiedChatData(chatData); - - } catch (error) { - console.warn('⚠️ Failed to fetch chat data:', error); - updateWorkflowStatus('idle'); - } - - } catch (error) { - console.error('❌ Error selecting workflow:', error); - } - }, [request, instanceId, apiBaseUrl, updateWorkflowStatus, processUnifiedChatData]); - - // === EXPOSE STATUS SETTER FOR OPTIMISTIC UPDATES === - const setWorkflowStatusOptimistic = useCallback((status: string) => { - updateWorkflowStatus(status); - }, [updateWorkflowStatus]); - - // === COMPUTED VALUES === - const isRunning = workflowStatus === 'running'; - const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false; - - return { - workflowId, - workflowStatus, - currentRound, - isRunning, - isStopping, - startingWorkflow, - messages, - logs, - dashboardLogs, - unifiedContentLogs, - latestStats, - hasRenderedLastMessage, - startWorkflow: handleStartWorkflow, - stopWorkflow: handleStopWorkflow, - resetWorkflow, - selectWorkflow, - setWorkflowStatusOptimistic - }; -} diff --git a/src/hooks/playground/useWorkflowOperations.ts b/src/hooks/playground/useWorkflowOperations.ts deleted file mode 100644 index 2e83052..0000000 --- a/src/hooks/playground/useWorkflowOperations.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from consolidated hook -export { useWorkflowOperations } from '../useWorkflows'; diff --git a/src/hooks/playground/useWorkflowPolling.ts b/src/hooks/playground/useWorkflowPolling.ts deleted file mode 100644 index 403f75d..0000000 --- a/src/hooks/playground/useWorkflowPolling.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { useRef, useCallback } from 'react'; - -interface PollingState { - activeWorkflowId: string | null; - isPolling: boolean; - isPollInProgress: boolean; - isPaused: boolean; - currentInterval: number; - failureCount: number; - rateLimitFailureCount: number; - timeoutId: NodeJS.Timeout | null; -} - -const BASE_INTERVAL = 5000; // 5 seconds -const MAX_INTERVAL = 10000; // 10 seconds -const BACKOFF_MULTIPLIER = 1.5; -const RATE_LIMIT_BACKOFF_MULTIPLIER = 2.0; -const MAX_RATE_LIMIT_FAILURES = 5; - -export type PollCallback = (workflowId: string) => Promise; - -export function useWorkflowPolling() { - const stateRef = useRef({ - activeWorkflowId: null, - isPolling: false, - isPollInProgress: false, - isPaused: false, - currentInterval: BASE_INTERVAL, - failureCount: 0, - rateLimitFailureCount: 0, - timeoutId: null - }); - - const pollCallbackRef = useRef(null); - - const calculateInterval = useCallback((isRateLimit: boolean = false): number => { - const state = stateRef.current; - const multiplier = isRateLimit ? RATE_LIMIT_BACKOFF_MULTIPLIER : BACKOFF_MULTIPLIER; - const newInterval = Math.min( - BASE_INTERVAL * Math.pow(multiplier, state.failureCount), - MAX_INTERVAL - ); - return Math.floor(newInterval); - }, []); - - const scheduleNextPoll = useCallback((interval: number) => { - const state = stateRef.current; - - // Clear any existing timeout - if (state.timeoutId) { - clearTimeout(state.timeoutId); - state.timeoutId = null; - } - - // Don't schedule if not polling or paused - if (!state.isPolling || state.isPaused || !state.activeWorkflowId) { - return; - } - - // Schedule next poll - state.timeoutId = setTimeout(() => { - state.timeoutId = null; - doPolling(); - }, interval); - }, []); - - const doPolling = useCallback(async () => { - const state = stateRef.current; - - // Prevent concurrent polls - if (state.isPollInProgress) { - return; - } - - // Validate workflow is still active - if (!state.activeWorkflowId || !state.isPolling || state.isPaused) { - return; - } - - const workflowId = state.activeWorkflowId; - state.isPollInProgress = true; - - try { - if (pollCallbackRef.current) { - await pollCallbackRef.current(workflowId); - } - - // Success - reset failure counts and interval - state.failureCount = 0; - state.rateLimitFailureCount = 0; - state.currentInterval = BASE_INTERVAL; - - // Schedule next poll - scheduleNextPoll(state.currentInterval); - } catch (error: any) { - // Handle errors - const isRateLimit = error?.status === 429 || error?.response?.status === 429; - - if (isRateLimit) { - state.rateLimitFailureCount++; - - // Stop polling after too many rate limit errors - if (state.rateLimitFailureCount >= MAX_RATE_LIMIT_FAILURES) { - console.error('Too many rate limit errors, stopping polling'); - stopPolling(); - return; - } - } else { - state.rateLimitFailureCount = 0; // Reset rate limit count on non-rate-limit errors - } - - state.failureCount++; - const nextInterval = calculateInterval(isRateLimit); - state.currentInterval = nextInterval; - - console.warn(`Polling error (attempt ${state.failureCount}):`, error); - - // Schedule next poll with backoff - scheduleNextPoll(nextInterval); - } finally { - state.isPollInProgress = false; - } - }, [scheduleNextPoll, calculateInterval]); - - const startPolling = useCallback((workflowId: string, callback: PollCallback) => { - const state = stateRef.current; - - // Stop any existing polling - if (state.isPolling) { - stopPolling(); - } - - // Validate workflow ID - if (!workflowId || typeof workflowId !== 'string') { - console.error('Invalid workflow ID for polling:', workflowId); - return; - } - - // Set up polling state - state.activeWorkflowId = workflowId; - state.isPolling = true; - state.isPaused = false; - state.failureCount = 0; - state.rateLimitFailureCount = 0; - state.currentInterval = BASE_INTERVAL; - pollCallbackRef.current = callback; - - // Execute immediate first poll (no delay) - doPolling(); - }, [doPolling]); - - const stopPolling = useCallback(() => { - const state = stateRef.current; - - // Clear timeout - if (state.timeoutId) { - clearTimeout(state.timeoutId); - state.timeoutId = null; - } - - // Reset state - state.isPolling = false; - state.isPollInProgress = false; - state.activeWorkflowId = null; - state.failureCount = 0; - state.rateLimitFailureCount = 0; - state.currentInterval = BASE_INTERVAL; - state.isPaused = false; - pollCallbackRef.current = null; - }, []); - - const pausePolling = useCallback(() => { - const state = stateRef.current; - state.isPaused = true; - }, []); - - const resumePolling = useCallback(() => { - const state = stateRef.current; - if (state.isPolling && state.isPaused) { - state.isPaused = false; - // Resume polling immediately - if (!state.isPollInProgress) { - scheduleNextPoll(0); - } - } - }, [scheduleNextPoll]); - - const isPolling = useCallback((): boolean => { - return stateRef.current.isPolling && !stateRef.current.isPaused; - }, []); - - const getActiveWorkflowId = useCallback((): string | null => { - return stateRef.current.activeWorkflowId; - }, []); - - return { - startPolling, - stopPolling, - pausePolling, - resumePolling, - isPolling, - getActiveWorkflowId - }; -} - diff --git a/src/hooks/playground/useWorkflows.ts b/src/hooks/playground/useWorkflows.ts deleted file mode 100644 index ebf367b..0000000 --- a/src/hooks/playground/useWorkflows.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { useApiRequest } from '../useApi'; -import { useWorkflowSelection } from '../../contexts/WorkflowSelectionContext'; -import { fetchWorkflows as fetchWorkflowsApi, type Workflow } from '../../api/workflowApi'; -import { getWorkflowApiBaseUrl } from '../useWorkflows'; - -export function useWorkflows(instanceId?: string, featureCode: string = 'chatplayground') { - const [workflows, setWorkflows] = useState([]); - const [isRefetching, setIsRefetching] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const { request } = useApiRequest(); - const { selectedWorkflowId, clearWorkflow } = useWorkflowSelection(); - - const apiBaseUrl = useMemo( - () => getWorkflowApiBaseUrl(instanceId, featureCode), - [instanceId, featureCode] - ); - - const fetchWorkflows = useCallback(async () => { - if (!apiBaseUrl) { - console.warn('⚠️ useWorkflows: No apiBaseUrl available (missing instanceId), skipping fetch'); - return; - } - try { - setLoading(true); - setError(null); - - console.log('🔄 useWorkflows: Fetching workflows from API...', { apiBaseUrl }); - const workflowList = await fetchWorkflowsApi(request, undefined, apiBaseUrl); - console.log('✅ useWorkflows: Fetched workflows:', workflowList); - - if (Array.isArray(workflowList)) { - setWorkflows(workflowList); - console.log(`✅ useWorkflows: Set ${workflowList.length} workflows in state`); - } else { - console.warn('⚠️ useWorkflows: API returned non-array data:', workflowList); - setWorkflows([]); - } - } catch (error: any) { - console.error('❌ useWorkflows: Error fetching workflows:', error); - setError(error.message || 'Failed to fetch workflows'); - setWorkflows([]); - } finally { - setLoading(false); - } - }, [request, apiBaseUrl]); - - useEffect(() => { - fetchWorkflows(); - }, [fetchWorkflows]); - - useEffect(() => { - const handleWorkflowDeleted = (event: CustomEvent<{ workflowIds: string[] }>) => { - const deletedIds = event.detail.workflowIds; - fetchWorkflows(); - if (selectedWorkflowId && deletedIds.includes(selectedWorkflowId)) { - clearWorkflow(); - } - }; - - const handleWorkflowCreated = () => { - // Immediately refetch workflows list to include the newly created workflow - fetchWorkflows(); - }; - - window.addEventListener('workflowDeleted', handleWorkflowDeleted as EventListener); - window.addEventListener('workflowCreated', handleWorkflowCreated as EventListener); - return () => { - window.removeEventListener('workflowDeleted', handleWorkflowDeleted as EventListener); - window.removeEventListener('workflowCreated', handleWorkflowCreated as EventListener); - }; - }, [fetchWorkflows, selectedWorkflowId, clearWorkflow]); - - const refetch = useCallback(async () => { - setIsRefetching(true); - try { - await fetchWorkflows(); - } finally { - setIsRefetching(false); - } - }, [fetchWorkflows]); - - return { - workflows, - loading, - isRefetching, - error, - refetch - }; -} - diff --git a/src/hooks/usePlayground.ts b/src/hooks/usePlayground.ts index 046b361..9ffda7f 100644 --- a/src/hooks/usePlayground.ts +++ b/src/hooks/usePlayground.ts @@ -13,9 +13,12 @@ export interface UserInputRequest { metadata?: Record; } -export type { WorkflowFile } from './playground/useDashboardInputForm'; - -export { useWorkflows } from './playground/useWorkflows'; -export { useWorkflowOperations } from './playground/useWorkflowOperations'; -export { useWorkflowLifecycle } from './playground/useWorkflowLifecycle'; -export { useDashboardInputForm, createDashboardHook } from './playground/useDashboardInputForm'; +export interface WorkflowFile { + id: string; + fileId: string; + fileName: string; + fileSize: number; + mimeType: string; + messageId?: string; + source?: 'user_uploaded' | 'ai_created'; +} diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index ebaf1c5..512ee41 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -49,7 +49,6 @@ export interface PaginationParams { /** Get apiBaseUrl from instanceId and featureCode for feature-scoped workflow APIs */ export function getWorkflowApiBaseUrl(instanceId: string | undefined, featureCode: string | undefined): string | undefined { if (!instanceId || !featureCode) return undefined; - if (featureCode === 'chatplayground') return `/api/chatplayground/${instanceId}`; if (featureCode === 'automation') return `/api/automations/${instanceId}`; return undefined; } diff --git a/src/locales/de.ts b/src/locales/de.ts index 1f0c637..87d282d 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -718,11 +718,6 @@ export default { 'warning.duplicate_file.title': 'Datei bereits vorhanden', 'warning.duplicate_file.message': 'Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.', - // Chat Playground Page - 'chatPlayground.title': 'Chat Playground', - 'chatPlayground.description': 'Workflow-Ausführung und Chat-Interaktion', - 'chatPlayground.subtitle': 'Chat-basierte Workflow-Steuerung', - // Automations Page 'automations.title': 'Automatisierungen', 'automations.description': 'Workflow-Automatisierungen verwalten', diff --git a/src/locales/en.ts b/src/locales/en.ts index 6d9bbe0..8989b10 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -718,11 +718,6 @@ export default { 'warning.duplicate_file.title': 'File Already Exists', 'warning.duplicate_file.message': 'The file "{fileName}" already exists with identical content. The existing file will be reused.', - // Chat Playground Page - 'chatPlayground.title': 'Chat Playground', - 'chatPlayground.description': 'Workflow execution and chat interaction', - 'chatPlayground.subtitle': 'Chat-based workflow control', - // Automations Page 'automations.title': 'Automations', 'automations.description': 'Manage workflow automations', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 83f8b06..9c396fa 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -718,11 +718,6 @@ export default { 'warning.duplicate_file.title': 'Fichier Déjà Existant', 'warning.duplicate_file.message': 'Le fichier "{fileName}" existe déjà avec un contenu identique. Le fichier existant sera réutilisé.', - // Chat Playground Page - 'chatPlayground.title': 'Chat Playground', - 'chatPlayground.description': 'Exécution de workflow et interaction chat', - 'chatPlayground.subtitle': 'Contrôle des workflows par chat', - // Automations Page 'automations.title': 'Automatisations', 'automations.description': 'Gérer les automatisations de workflow', diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 80a10e1..56deae2 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -27,17 +27,12 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi // RealEstate Views import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; -// Chat Playground Views (reusing existing workflow pages) -import { PlaygroundPage, WorkflowsPage } from './workflows'; - // Automation Views import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation'; -// CodeEditor Views -import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor'; - // Workspace Views import { WorkspacePage } from './views/workspace/WorkspacePage'; +import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage'; import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage'; // Teamsbot Views @@ -128,21 +123,14 @@ const VIEW_COMPONENTS: Record> = { dashboard: RealEstatePekView, 'instance-roles': RealEstateInstanceRolesPlaceholder, }, - chatplayground: { - playground: PlaygroundPage, - workflows: WorkflowsPage, - }, automation: { definitions: AutomationDefinitionsView, templates: AutomationTemplatesView, logs: AutomationLogsView, }, - codeeditor: { - editor: CodeEditorPage, - workflows: CodeEditorWorkflowsPage, - }, workspace: { dashboard: WorkspacePage, + editor: WorkspaceEditorPage, settings: WorkspaceSettingsPage, }, teamsbot: { @@ -208,8 +196,8 @@ export const FeatureViewPage: React.FC = ({ view }) => { } // Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level; - // other workspace views (e.g. settings) use the standard FeatureViewPage rendering. - if (featureCode === 'workspace' && view !== 'settings') { + // other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering. + if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') { return null; } diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index dc9fb03..febcce3 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { FaCogs, FaComments, FaFileAlt, FaHeadset } from 'react-icons/fa'; +import { FaCogs, FaHeadset } from 'react-icons/fa'; import { useLanguage } from '../providers/language/LanguageContext'; import { useStore } from '../hooks/useStore'; import type { StoreFeature } from '../api/storeApi'; @@ -15,8 +15,6 @@ import styles from './Store.module.css'; const FEATURE_ICONS: Record = { automation: , - chatplayground: , - codeeditor: , teamsbot: , }; @@ -26,16 +24,6 @@ const FEATURE_DESCRIPTIONS: Record> = { en: 'Create and manage automations to handle recurring tasks efficiently.', fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.', }, - chatplayground: { - de: 'Teste und experimentiere mit AI-Chat-Modellen in einer interaktiven Umgebung.', - en: 'Test and experiment with AI chat models in an interactive environment.', - fr: 'Testez et experimentez avec des modeles de chat IA dans un environnement interactif.', - }, - codeeditor: { - de: 'AI-gestuetzter Editor fuer Text-Dateien mit Cursor-artigem Chat und Diff-Preview.', - en: 'AI-powered editor for text files with Cursor-style chat and diff preview.', - fr: 'Editeur de fichiers texte assiste par IA avec chat et apercu des modifications.', - }, teamsbot: { de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.', en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.', diff --git a/src/pages/views/codeeditor/CodeEditor.module.css b/src/pages/views/codeeditor/CodeEditor.module.css deleted file mode 100644 index 934aaf5..0000000 --- a/src/pages/views/codeeditor/CodeEditor.module.css +++ /dev/null @@ -1,496 +0,0 @@ -/* CodeEditor Feature Styles */ - -.container { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -.panels { - display: flex; - flex: 1; - overflow: hidden; -} - -.filePanel, -.chatPanel, -.diffPanel { - display: flex; - flex-direction: column; - overflow: hidden; -} - -.mainArea { - display: flex; - overflow: hidden; -} - -.divider { - width: 6px; - cursor: col-resize; - background: var(--border-color, #e0e0e0); - flex-shrink: 0; - transition: background 0.15s; -} - -.divider:hover { - background: var(--primary-color, #4a90d9); -} - -.dragging { - cursor: col-resize; - user-select: none; -} - -.dragging .divider { - background: var(--primary-color, #4a90d9); -} - -/* File List Panel */ -.fileList { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -.panelHeader { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - border-bottom: 1px solid var(--border-color, #e0e0e0); -} - -.panelHeader h3 { - margin: 0; - font-size: 14px; - font-weight: 600; -} - -.selectedCount { - font-size: 12px; - color: var(--text-secondary, #666); -} - -.dragHint { - font-size: 11px; - color: var(--text-secondary, #999); - font-style: italic; -} - -.fileItems { - flex: 1; - overflow-y: auto; - padding: 4px 0; -} - -.fileItem { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 16px; - cursor: grab; - transition: background 0.1s; -} - -.fileItem:hover { - background: var(--hover-bg, #f5f5f5); -} - -.fileItem:active { - cursor: grabbing; - opacity: 0.7; -} - -.dragHandle { - color: var(--text-secondary, #ccc); - flex-shrink: 0; - font-size: 10px; -} - -.fileItem:hover .dragHandle { - color: var(--text-secondary, #999); -} - -.dateGroup { - margin-bottom: 4px; -} - -.dateGroupHeader { - padding: 6px 16px 2px; - font-size: 11px; - font-weight: 600; - color: var(--text-secondary, #999); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.fileIcon { - color: var(--text-secondary, #666); - flex-shrink: 0; - font-size: 12px; -} - -.fileName { - flex: 1; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.fileSize { - font-size: 11px; - color: var(--text-secondary, #999); - flex-shrink: 0; -} - -.emptyState { - padding: 24px 16px; - text-align: center; - color: var(--text-secondary, #999); - font-size: 13px; -} - -/* Chat Panel */ -.messagesArea { - flex: 1; - overflow-y: auto; - padding: 16px; -} - -.inputArea { - border-top: 1px solid var(--border-color, #e0e0e0); - padding: 12px 16px; -} - -.input { - width: 100%; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - padding: 10px 12px; - font-size: 14px; - resize: none; - font-family: inherit; - outline: none; - transition: border-color 0.15s; -} - -.input:focus { - border-color: var(--primary-color, #4a90d9); -} - -.input:disabled { - background: var(--disabled-bg, #f5f5f5); -} - -.inputDropTarget { - border-color: var(--primary-color, #4a90d9); - background: var(--selected-bg, #e8f0fe); - box-shadow: 0 0 0 2px var(--primary-color, #4a90d9) inset; -} - -/* Mode Toggle */ -.modeToggle { - display: flex; - gap: 4px; - margin-bottom: 8px; -} - -.modeButton { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 12px; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 4px; - background: transparent; - font-size: 12px; - cursor: pointer; - transition: all 0.15s; - color: var(--text-secondary, #666); -} - -.modeButton:hover { - background: var(--hover-bg, #f5f5f5); -} - -.modeActive { - background: var(--primary-color, #4a90d9); - color: white; - border-color: var(--primary-color, #4a90d9); -} - -.modeActive:hover { - background: var(--primary-dark, #3a7bc8); -} - -.modeButton:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Agent Progress */ -.agentProgress { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - margin: 8px 0; - background: var(--info-light, #e8f4fd); - border-radius: 6px; - font-size: 12px; - color: var(--info-dark, #0c5460); - animation: pulse 2s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } -} - -.inputActions { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 8px; -} - -.fileCount { - font-size: 12px; - color: var(--text-secondary, #666); -} - -.sendButton, -.stopButton { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 16px; - border: none; - border-radius: 6px; - font-size: 13px; - cursor: pointer; - transition: opacity 0.15s; -} - -.sendButton { - background: var(--primary-color, #4a90d9); - color: white; -} - -.sendButton:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.stopButton { - background: var(--danger-color, #dc3545); - color: white; -} - -/* Diff Preview Panel */ -.diffPreview { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -.diffItems { - flex: 1; - overflow-y: auto; - padding: 8px; -} - -.diffCard { - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - margin-bottom: 8px; - overflow: hidden; -} - -.diffCard_pending { - border-color: var(--warning-color, #ffc107); -} - -.diffCard_accepted { - border-color: var(--success-color, #28a745); - opacity: 0.7; -} - -.diffCard_rejected { - border-color: var(--danger-color, #dc3545); - opacity: 0.5; -} - -.diffCardHeader { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 12px; - background: var(--surface-bg, #f8f9fa); - border-bottom: 1px solid var(--border-color, #e0e0e0); -} - -.diffFileName { - font-size: 13px; - font-weight: 600; - font-family: monospace; -} - -.diffStatus { - font-size: 11px; - padding: 2px 8px; - border-radius: 10px; - text-transform: uppercase; - font-weight: 600; -} - -.diffStatus_pending { - background: var(--warning-light, #fff3cd); - color: var(--warning-dark, #856404); -} - -.diffStatus_accepted { - background: var(--success-light, #d4edda); - color: var(--success-dark, #155724); -} - -.diffStatus_rejected { - background: var(--danger-light, #f8d7da); - color: var(--danger-dark, #721c24); -} - -.diffContent { - padding: 8px 12px; - max-height: 300px; - overflow-y: auto; -} - -.diffOld, -.diffNew { - margin-bottom: 8px; -} - -.diffLabel { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - color: var(--text-secondary, #666); - margin-bottom: 4px; -} - -.diffOld pre, -.diffNew pre { - margin: 0; - font-size: 12px; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; - padding: 8px; - border-radius: 4px; - overflow-x: auto; -} - -.diffOld pre { - background: var(--danger-light, #fff0f0); -} - -.diffNew pre { - background: var(--success-light, #f0fff0); -} - -.diffActions { - display: flex; - gap: 8px; - padding: 8px 12px; - border-top: 1px solid var(--border-color, #e0e0e0); -} - -.acceptButton, -.rejectButton { - display: flex; - align-items: center; - gap: 4px; - padding: 6px 12px; - border: none; - border-radius: 4px; - font-size: 12px; - cursor: pointer; - transition: opacity 0.15s; -} - -.acceptButton { - background: var(--success-color, #28a745); - color: white; -} - -.rejectButton { - background: var(--danger-color, #dc3545); - color: white; -} - -/* Workflows Page */ -.workflowsPage { - padding: 16px 24px; -} - -.workflowsHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -} - -.workflowsHeader h3 { - margin: 0; -} - -.refreshButton { - padding: 6px 14px; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 4px; - background: transparent; - cursor: pointer; - font-size: 13px; -} - -.refreshButton:hover { - background: var(--hover-bg, #f5f5f5); -} - -.workflowTable { - width: 100%; - border-collapse: collapse; - font-size: 13px; -} - -.workflowTable th, -.workflowTable td { - padding: 8px 12px; - text-align: left; - border-bottom: 1px solid var(--border-color, #e0e0e0); -} - -.workflowTable th { - font-weight: 600; - color: var(--text-secondary, #666); - font-size: 12px; - text-transform: uppercase; -} - -.statusBadge { - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; -} - -.status_running { background: var(--info-light, #e8f4fd); color: var(--info-dark, #0c5460); } -.status_completed { background: var(--success-light, #d4edda); color: var(--success-dark, #155724); } -.status_stopped { background: var(--warning-light, #fff3cd); color: var(--warning-dark, #856404); } -.status_error { background: var(--danger-light, #f8d7da); color: var(--danger-dark, #721c24); } -.status_unknown { background: #f0f0f0; color: #666; } diff --git a/src/pages/views/codeeditor/CodeEditorPage.tsx b/src/pages/views/codeeditor/CodeEditorPage.tsx deleted file mode 100644 index 05c2245..0000000 --- a/src/pages/views/codeeditor/CodeEditorPage.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/** - * CodeEditorPage - * - * Main page for the CodeEditor feature. - * Three-panel layout: FileList (left) | Chat (center) | DiffPreview (right) - * Files are dragged from FileList into the prompt textarea as @fileName references. - */ - -import React, { useState, useRef, useCallback } from 'react'; -import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; -import { useCodeEditor } from './useCodeEditor'; -import { FileListPanel } from './FileListPanel'; -import { DiffPreviewPanel } from './DiffPreviewPanel'; -import { useResizablePanels } from '../../../hooks/useResizablePanels'; -import { Messages } from '../../../components/UiComponents'; -import { FaPaperPlane, FaStop, FaRobot, FaEdit } from 'react-icons/fa'; -import styles from './CodeEditor.module.css'; - -export const CodeEditorPage: React.FC = () => { - const { instance } = useCurrentInstance(); - const instanceId = instance?.id || ''; - const inputRef = useRef(null); - const [inputValue, setInputValue] = useState(''); - const [mode, setMode] = useState<'simple' | 'agent'>('simple'); - const [isDragOver, setIsDragOver] = useState(false); - - const { - messages, - pendingEdits, - acceptEdit, - rejectEdit, - isProcessing, - sendMessage, - stopProcessing, - files, - agentProgress, - } = useCodeEditor(instanceId); - - const { - leftWidth: fileListWidth, - handleMouseDown: fileListMouseDown, - containerRef: outerContainerRef, - isDragging: isDraggingLeft, - } = useResizablePanels({ - storageKey: 'codeeditor-filelist-width', - defaultLeftWidth: 20, - minLeftWidth: 10, - maxLeftWidth: 40, - }); - - const { - leftWidth: chatWidth, - handleMouseDown: chatMouseDown, - containerRef: innerContainerRef, - isDragging: isDraggingRight, - } = useResizablePanels({ - storageKey: 'codeeditor-chat-width', - defaultLeftWidth: 60, - minLeftWidth: 30, - maxLeftWidth: 80, - }); - - const handleSubmit = useCallback(() => { - const trimmed = inputValue.trim(); - if (!trimmed || isProcessing) return; - sendMessage(trimmed, mode); - setInputValue(''); - }, [inputValue, isProcessing, sendMessage, mode]); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }, [handleSubmit]); - - const handleDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes('application/x-codeeditor-file')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - setIsDragOver(true); - } - }, []); - - const handleDragLeave = useCallback(() => { - setIsDragOver(false); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - - const fileDataStr = e.dataTransfer.getData('application/x-codeeditor-file'); - if (!fileDataStr) return; - - try { - const fileData = JSON.parse(fileDataStr); - const tag = `@${fileData.fileName}`; - const textarea = inputRef.current; - if (textarea) { - const pos = textarea.selectionStart || inputValue.length; - const before = inputValue.slice(0, pos); - const after = inputValue.slice(pos); - const spaceBefore = before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n') ? ' ' : ''; - const spaceAfter = after.length > 0 && !after.startsWith(' ') && !after.startsWith('\n') ? ' ' : ''; - const newValue = `${before}${spaceBefore}${tag}${spaceAfter}${after}`; - setInputValue(newValue); - requestAnimationFrame(() => { - const newPos = pos + spaceBefore.length + tag.length + spaceAfter.length; - textarea.focus(); - textarea.setSelectionRange(newPos, newPos); - }); - } else { - setInputValue(prev => prev + (prev && !prev.endsWith(' ') ? ' ' : '') + tag + ' '); - } - } catch { - // ignore malformed drop data - } - }, [inputValue]); - - return ( -
-
- {/* Left: File List */} -
- -
- -
- - {/* Center + Right */} -
- {/* Center: Chat */} -
-
- - - {agentProgress && isProcessing && ( -
- - - Round {agentProgress.round} | {agentProgress.totalToolCalls} tools |{' '} - {agentProgress.costCHF.toFixed(4)} CHF - -
- )} -
- -
-
- - -
- -