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/billingApi.ts b/src/api/billingApi.ts index 5e25261..e82e438 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -99,6 +99,7 @@ export interface CreditAddRequest { export interface CheckoutCreateRequest { userId?: string; amount: number; + returnUrl: string; } export interface CheckoutCreateResponse { diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 8c94328..b079d6a 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -176,21 +176,116 @@ export async function deleteFiles( request: ApiRequestFunction, fileIds: string[] ): Promise> { - const results = await Promise.allSettled( - fileIds.map(fileId => - request({ - url: `/api/files/${fileId}`, - method: 'delete' - }).then(() => ({ success: true, fileId })) - .catch((error) => ({ success: false, fileId, error })) - ) - ); - - return results.map((result, index) => { - if (result.status === 'fulfilled') { - return result.value; - } - return { success: false, fileId: fileIds[index], error: result.reason }; + const uniqueIds = [...new Set(fileIds.filter(Boolean))]; + if (uniqueIds.length === 0) return []; + await request({ + url: '/api/files/batch-delete', + method: 'post', + data: { fileIds: uniqueIds } + }); + return uniqueIds.map(fileId => ({ success: true, fileId })); +} + +export async function deleteFolders( + request: ApiRequestFunction, + folderIds: string[], + recursiveFolders: boolean = true +): Promise<{ deletedFiles: number; deletedFolders: number }> { + const uniqueIds = [...new Set(folderIds.filter(Boolean))]; + if (uniqueIds.length === 0) return { deletedFiles: 0, deletedFolders: 0 }; + return await request({ + url: '/api/files/batch-delete', + method: 'post', + data: { folderIds: uniqueIds, recursiveFolders } + }); +} + +// ============================================================================ +// FOLDER API FUNCTIONS +// ============================================================================ + +export interface FolderInfo { + id: string; + name: string; + parentId: string | null; + mandateId?: string; + featureInstanceId?: string; + createdAt?: number; +} + +export async function fetchFolders( + request: ApiRequestFunction, + parentId?: string | null +): Promise { + const params: any = {}; + if (parentId !== undefined && parentId !== null) { + params.parentId = parentId; + } + const data = await request({ + url: '/api/files/folders', + method: 'get', + params, + }); + return Array.isArray(data) ? data : []; +} + +export async function createFolder( + request: ApiRequestFunction, + name: string, + parentId?: string | null +): Promise { + return await request({ + url: '/api/files/folders', + method: 'post', + data: { name, parentId: parentId || null }, + }); +} + +export async function renameFolder( + request: ApiRequestFunction, + folderId: string, + name: string +): Promise { + return await request({ + url: `/api/files/folders/${folderId}`, + method: 'put', + data: { name }, + }); +} + +export async function deleteFolderApi( + request: ApiRequestFunction, + folderId: string, + recursive: boolean = false +): Promise { + return await request({ + url: `/api/files/folders/${folderId}`, + method: 'delete', + params: { recursive }, + }); +} + +export async function moveFolder( + request: ApiRequestFunction, + folderId: string, + targetParentId: string | null +): Promise { + return await request({ + url: `/api/files/folders/${folderId}/move`, + method: 'post', + data: { targetParentId }, + }); +} + +export async function moveFile( + request: ApiRequestFunction, + fileId: string, + targetFolderId: string | null +): Promise { + return await request({ + url: `/api/files/${fileId}/move`, + method: 'post', + data: { targetFolderId }, }); } diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 7254640..fc890d5 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -57,8 +57,8 @@ export interface StartWorkflowResponse extends Workflow { export interface ChatDataResponse { messages: WorkflowMessage[]; logs: WorkflowLog[]; - stats: WorkflowStats[]; documents: WorkflowDocument[]; + workflowCost: number; } // Type for the request function passed to API functions @@ -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 }; @@ -259,35 +259,25 @@ export async function fetchChatData( console.log('πŸ“₯ fetchChatData response:', data); - // Handle unified items format: { items: [{ type: 'message'|'log'|'stat', item: {...}, createdAt: ... }] } + const workflowCost: number = data.workflowCost ?? 0; + if (data.items && Array.isArray(data.items)) { const messages: WorkflowMessage[] = []; const logs: WorkflowLog[] = []; - const stats: WorkflowStats[] = []; const documents: WorkflowDocument[] = []; data.items.forEach((item: any) => { if (item.type === 'message') { - // Handle both formats: item.item or direct item data const messageData = item.item || item; if (messageData && (messageData.id || messageData.message)) { messages.push(messageData); - } else { - console.warn('⚠️ Invalid message item:', item); } } else if (item.type === 'log') { const logData = item.item || item; if (logData) { logs.push(logData); } - } else if (item.type === 'stat') { - const statData = item.item || item; - if (statData) { - stats.push(statData); - } - } - // Documents might be in items or separate - if (item.type === 'document') { + } else if (item.type === 'document') { const docData = item.item || item; if (docData) { documents.push(docData); @@ -295,33 +285,25 @@ export async function fetchChatData( } }); - console.log('πŸ“¦ Extracted from items:', { - messages: messages.length, - logs: logs.length, - stats: stats.length, - documents: documents.length - }); - return { messages, logs, - stats, - documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : []) + documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : []), + workflowCost }; } - // Fallback to direct format: { messages: [], logs: [], stats: [] } return { messages: Array.isArray(data.messages) ? data.messages : [], logs: Array.isArray(data.logs) ? data.logs : [], - stats: Array.isArray(data.stats) ? data.stats : [], - documents: Array.isArray(data.documents) ? data.documents : [] + documents: Array.isArray(data.documents) ? data.documents : [], + workflowCost }; } /** * 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( @@ -336,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'; } @@ -344,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 }), @@ -354,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 @@ -377,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, @@ -385,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.module.css b/src/components/FolderTree/FolderTree.module.css new file mode 100644 index 0000000..52df54d --- /dev/null +++ b/src/components/FolderTree/FolderTree.module.css @@ -0,0 +1,157 @@ +.folderTree { + font-size: 0.875rem; + user-select: none; +} + +.treeNode { + display: flex; + align-items: center; + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; + gap: 6px; + min-height: 32px; + position: relative; +} + +.treeNode:hover { + background: var(--color-bg-hover, rgba(0, 0, 0, 0.04)); +} + +.treeNode.selected { + background: var(--color-bg-selected, rgba(25, 118, 210, 0.08)); + font-weight: 600; +} + +.treeNode.multiSelected { + background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14)); + box-shadow: inset 3px 0 0 var(--color-primary, #1976d2); +} + +.treeNode.multiSelected:hover { + background: var(--color-bg-multi-selected-hover, rgba(25, 118, 210, 0.20)); +} + +.treeNode.dropTarget { + background: var(--color-bg-drop, rgba(25, 118, 210, 0.15)); + outline: 2px dashed var(--color-primary, #1976d2); + outline-offset: -2px; +} + +.treeNode.dragging { + opacity: 0.5; +} + +.chevron { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: transform 0.15s ease; + color: var(--color-text-secondary, #666); + font-size: 10px; +} + +.chevron.expanded { + transform: rotate(90deg); +} + +.chevron.empty { + visibility: hidden; +} + +.folderIcon { + flex-shrink: 0; + color: var(--color-text-secondary, #888); + font-size: 14px; +} + +.folderName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.renameInput { + flex: 1; + border: 1px solid var(--color-primary, #1976d2); + border-radius: 3px; + padding: 1px 4px; + font-size: inherit; + font-family: inherit; + outline: none; + min-width: 0; +} + +.actions { + display: none; + gap: 2px; + margin-left: auto; + flex-shrink: 0; +} + +.treeNode:hover .actions { + display: flex; +} + +.actionBtn { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; + color: var(--color-text-secondary, #888); + font-size: 12px; + line-height: 1; + display: flex; + align-items: center; +} + +.actionBtn:hover { + background: var(--color-bg-hover, rgba(0, 0, 0, 0.08)); + color: var(--color-text-primary, #333); +} + +.actionBtn.danger:hover { + color: var(--color-error, #d32f2f); +} + +.children { + padding-left: 16px; +} + +.rootLabel { + font-weight: 600; + color: var(--color-text-primary, #333); +} + +/* File nodes inside the tree */ +.fileNode { + cursor: pointer; +} + +.fileNode:hover { + background: var(--color-bg-hover, rgba(0, 0, 0, 0.04)); +} + +.fileIcon { + flex-shrink: 0; + font-size: 12px; +} + +.fileSize { + font-size: 10px; + color: var(--color-text-secondary, #999); + flex-shrink: 0; + margin-left: auto; +} + +.rootActions { + display: flex; + gap: 2px; + margin-left: auto; + flex-shrink: 0; +} diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx new file mode 100644 index 0000000..7e4860a --- /dev/null +++ b/src/components/FolderTree/FolderTree.tsx @@ -0,0 +1,741 @@ +/** + * FolderTree – Shared recursive folder/file tree component. + * + * Used on the Files page and in the Workspace chat. + * Supports: + * - Alphabetical sorting per level (folders first, then files) + * - Multi-selection (CTRL+click, SHIFT+click) with visual highlight + * - Batch drag-and-drop for selected items + * - Inline CRUD icons for folders + * - showFiles mode renders files inline under their parent folder + * - Drag-out: sets application/tree-items on dataTransfer for external drop targets + */ + +import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa'; +import styles from './FolderTree.module.css'; + +/* ── Public types ──────────────────────────────────────────────────────── */ + +export interface FolderNode { + id: string; + name: string; + parentId: string | null; + children?: FolderNode[]; +} + +export interface FileNode { + id: string; + fileName: string; + mimeType?: string; + fileSize?: number; + folderId?: string | null; +} + +export interface TreeItem { + id: string; + type: 'file' | 'folder'; + name: string; +} + +export interface FolderTreeProps { + folders: FolderNode[]; + files?: FileNode[]; + showFiles?: boolean; + selectedFolderId: string | null; + onSelect: (folderId: string | null) => void; + onFileSelect?: (fileId: string) => void; + selectedItemIds?: Set; + onSelectionChange?: (selectedIds: Set) => void; + expandedIds?: Set; + onToggleExpand?: (id: string) => void; + onRefresh?: () => void; + onCreateFolder?: (name: string, parentId: string | null) => Promise; + onRenameFolder?: (folderId: string, newName: string) => Promise; + onDeleteFolder?: (folderId: string) => Promise; + onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; + onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; + onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; + onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; + onRenameFile?: (fileId: string, newName: string) => Promise; + onDeleteFile?: (fileId: string) => Promise; + onDeleteFiles?: (fileIds: string[]) => Promise; + onDeleteFolders?: (folderIds: string[]) => Promise; + onDownloadFolder?: (folderId: string, folderName: string) => Promise; +} + +/* ── Helpers ───────────────────────────────────────────────────────────── */ + +function _buildTree(folders: FolderNode[]): FolderNode[] { + const map = new Map(); + const roots: FolderNode[] = []; + for (const f of folders) map.set(f.id, { ...f, children: [] }); + for (const f of folders) { + const node = map.get(f.id)!; + if (f.parentId && map.has(f.parentId)) { + map.get(f.parentId)!.children!.push(node); + } else { + roots.push(node); + } + } + const _sortLevel = (nodes: FolderNode[]) => { + nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + for (const n of nodes) { + if (n.children && n.children.length > 0) _sortLevel(n.children); + } + }; + _sortLevel(roots); + return roots; +} + +function _groupFilesByFolder(files: FileNode[]): Map { + const map = new Map(); + for (const f of files) { + const key = f.folderId || ''; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(f); + } + for (const [, arr] of map) { + arr.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' })); + } + return map; +} + +function _computeFlatList( + tree: FolderNode[], + expandedIds: Set, + showFiles: boolean, + filesByFolder: Map, +): TreeItem[] { + const result: TreeItem[] = []; + const _walk = (nodes: FolderNode[]) => { + for (const node of nodes) { + result.push({ id: node.id, type: 'folder', name: node.name }); + if (expandedIds.has(node.id)) { + if (node.children) _walk(node.children); + if (showFiles) { + for (const f of (filesByFolder.get(node.id) || [])) { + result.push({ id: f.id, type: 'file', name: f.fileName }); + } + } + } + } + }; + _walk(tree); + if (showFiles) { + for (const f of (filesByFolder.get('') || [])) { + result.push({ id: f.id, type: 'file', name: f.fileName }); + } + } + return result; +} + +function _fileIcon(mime?: string): string { + if (!mime) return '\uD83D\uDCC4'; + if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; + if (mime.includes('pdf')) return '\uD83D\uDCD5'; + if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8'; + if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA'; + if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9'; + if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6'; + if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD'; + if (mime.startsWith('audio/')) return '\uD83C\uDFB5'; + if (mime.startsWith('video/')) return '\uD83C\uDFA5'; + return '\uD83D\uDCC4'; +} + +/* ── Selection context threaded through the tree ──────────────────────── */ + +interface SelectionCtx { + selectedItemIds: Set; + selectedFileIds: string[]; + selectedFolderIds: string[]; + onItemClick: (id: string, type: 'file' | 'folder', e: React.MouseEvent) => void; + onItemDragStart: (e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => void; + onRenameFile?: (fileId: string, newName: string) => Promise; + onDeleteFile?: (fileId: string) => Promise; + onDeleteFiles?: (fileIds: string[]) => Promise; + onDeleteFolders?: (folderIds: string[]) => Promise; +} + +/* ── File node (leaf) ─────────────────────────────────────────────────── */ + +function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { + const [dragging, setDragging] = useState(false); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const isSelected = sel.selectedItemIds.has(file.id); + const multiSelected = sel.selectedItemIds.size > 1; + + const _handleRename = useCallback(async () => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== file.fileName && sel.onRenameFile) { + await sel.onRenameFile(file.id, trimmed); + } + setRenaming(false); + }, [renameValue, file.id, file.fileName, sel.onRenameFile]); + + const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) { + await sel.onDeleteFiles(sel.selectedFileIds); + } + }, [sel]); + + const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) { + await sel.onDeleteFolders(sel.selectedFolderIds); + } + }, [sel]); + + const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.onDeleteFile) await sel.onDeleteFile(file.id); + }, [file.id, sel]); + + return ( +
sel.onItemClick(file.id, 'file', e)} + draggable + onDragStart={(e) => { + sel.onItemDragStart(e, file.id, 'file', file.fileName); + setDragging(true); + }} + onDragEnd={() => setDragging(false)} + > + {_fileIcon(file.mimeType)} + {renaming ? ( + setRenameValue(e.target.value)} + onBlur={_handleRename} + onKeyDown={(e) => { + if (e.key === 'Enter') _handleRename(); + if (e.key === 'Escape') setRenaming(false); + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {file.fileName} + )} + {!renaming && file.fileSize != null && ( + + {(file.fileSize / 1024).toFixed(0)}K + + )} + {!renaming && ( + + {sel.onRenameFile && !multiSelected && ( + + )} + {multiSelected && isSelected ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : ( + (sel.onDeleteFile || sel.onDeleteFiles) && ( + + ) + )} + + )} +
+ ); +} + +/* ── Tree node (folder) ───────────────────────────────────────────────── */ + +interface TreeNodeProps { + node: FolderNode; + depth: number; + selectedFolderId: string | null; + expandedIds: Set; + showFiles: boolean; + filesByFolder: Map; + sel: SelectionCtx; + onToggle: (id: string) => void; + onSelect: (id: string | null) => void; + onCreateFolder?: (name: string, parentId: string | null) => Promise; + onRenameFolder?: (folderId: string, newName: string) => Promise; + onDeleteFolder?: (folderId: string) => Promise; + onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; + onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; + onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; + onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; + onDownloadFolder?: (folderId: string, folderName: string) => Promise; +} + +function _TreeNode({ + node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel, + onToggle, onSelect, + onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, + onDownloadFolder, +}: TreeNodeProps) { + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(node.name); + const [dropOver, setDropOver] = useState(false); + const [dragging, setDragging] = useState(false); + const inputRef = useRef(null); + const isExpanded = expandedIds.has(node.id); + const isNavSelected = selectedFolderId === node.id; + const isMultiSelected = sel.selectedItemIds.has(node.id); + const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : []; + const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0; + + useEffect(() => { + if (renaming && inputRef.current) inputRef.current.focus(); + }, [renaming]); + + const _handleRename = useCallback(async () => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== node.name && onRenameFolder) { + await onRenameFolder(node.id, trimmed); + } + setRenaming(false); + }, [renameValue, node.id, node.name, onRenameFolder]); + + const _handleAdd = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!onCreateFolder) return; + const name = prompt('Neuer Ordnername:'); + if (name?.trim()) { + await onCreateFolder(name.trim(), node.id); + if (!expandedIds.has(node.id)) onToggle(node.id); + } + }, [onCreateFolder, node.id, expandedIds, onToggle]); + + const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (onDeleteFolder) await onDeleteFolder(node.id); + }, [onDeleteFolder, node.id]); + + const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) { + await sel.onDeleteFolders(sel.selectedFolderIds); + } + }, [sel]); + + const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) { + await sel.onDeleteFiles(sel.selectedFileIds); + } + }, [sel]); + + const _handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropOver(true); + }, []); + + const _handleDragLeave = useCallback(() => setDropOver(false), []); + + const _handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + setDropOver(false); + + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); + if (treeItemsJson) { + const items: TreeItem[] = JSON.parse(treeItemsJson); + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + const folderIds = items.filter(i => i.type === 'folder' && i.id !== node.id).map(i => i.id); + + if (folderIds.length > 0 && onMoveFolders) { + await onMoveFolders(folderIds, node.id); + } else if (onMoveFolder) { + for (const fId of folderIds) await onMoveFolder(fId, node.id); + } + if (fileIds.length > 0 && onMoveFiles) { + await onMoveFiles(fileIds, node.id); + } else if (fileIds.length > 0 && onMoveFile) { + for (const fId of fileIds) await onMoveFile(fId, node.id); + } + return; + } + + const folderId = e.dataTransfer.getData('application/folder-id'); + const fileIdsJson = e.dataTransfer.getData('application/file-ids'); + const fileId = e.dataTransfer.getData('application/file-id'); + if (folderId && folderId !== node.id && onMoveFolder) { + await onMoveFolder(folderId, node.id); + } else if (fileIdsJson && onMoveFiles) { + await onMoveFiles(JSON.parse(fileIdsJson), node.id); + } else if (fileId && onMoveFile) { + await onMoveFile(fileId, node.id); + } + }, [node.id, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]); + + const nodeClasses = [ + styles.treeNode, + isNavSelected && !isMultiSelected ? styles.selected : '', + isMultiSelected ? styles.multiSelected : '', + dropOver ? styles.dropTarget : '', + dragging ? styles.dragging : '', + ].filter(Boolean).join(' '); + + return ( +
+
sel.onItemClick(node.id, 'folder', e)} + draggable + onDragStart={(e) => { + sel.onItemDragStart(e, node.id, 'folder', node.name); + setDragging(true); + }} + onDragEnd={() => setDragging(false)} + onDragOver={_handleDragOver} + onDragLeave={_handleDragLeave} + onDrop={_handleDrop} + > + { e.stopPropagation(); if (hasChildren) onToggle(node.id); }} + > + + + + {isExpanded ? : } + + {renaming ? ( + setRenameValue(e.target.value)} + onBlur={_handleRename} + onKeyDown={(e) => { + if (e.key === 'Enter') _handleRename(); + if (e.key === 'Escape') setRenaming(false); + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {node.name} + )} + + {onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {isMultiSelected && sel.selectedItemIds.size > 1 ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : onDeleteFolder && ( + + )} + +
+ {isExpanded && hasChildren && ( +
+ {node.children!.map((child) => ( + <_TreeNode + key={child.id} + node={child} + depth={depth + 1} + selectedFolderId={selectedFolderId} + expandedIds={expandedIds} + showFiles={showFiles} + filesByFolder={filesByFolder} + sel={sel} + onToggle={onToggle} + onSelect={onSelect} + onCreateFolder={onCreateFolder} + onRenameFolder={onRenameFolder} + onDeleteFolder={onDeleteFolder} + onMoveFolder={onMoveFolder} + onMoveFolders={onMoveFolders} + onMoveFile={onMoveFile} + onMoveFiles={onMoveFiles} + onDownloadFolder={onDownloadFolder} + /> + ))} + {folderFiles.map((file) => ( + <_FileItem key={file.id} file={file} sel={sel} /> + ))} +
+ )} +
+ ); +} + +/* ── Root component ────────────────────────────────────────────────────── */ + +export default function FolderTree({ + folders, files, showFiles = false, selectedFolderId, onSelect, onFileSelect, + selectedItemIds: externalSelectedIds, onSelectionChange, + expandedIds: externalExpandedIds, onToggleExpand, + onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, + onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, +}: FolderTreeProps) { + const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); + const [rootDropOver, setRootDropOver] = useState(false); + const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); + const lastClickedIdRef = useRef(null); + + const expandedIds = externalExpandedIds ?? internalExpandedIds; + + const tree = useMemo(() => _buildTree(folders), [folders]); + const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]); + const rootFiles = showFiles ? (filesByFolder.get('') || []) : []; + + const selectedItemIds = externalSelectedIds ?? internalSelectedIds; + + const flatList = useMemo( + () => _computeFlatList(tree, expandedIds, showFiles, filesByFolder), + [tree, expandedIds, showFiles, filesByFolder], + ); + + const _handleToggle = useCallback((id: string) => { + if (onToggleExpand) { + onToggleExpand(id); + return; + } + setInternalExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const _setSelection = useCallback((ids: Set) => { + if (onSelectionChange) { + onSelectionChange(ids); + } else { + setInternalSelectedIds(ids); + } + }, [onSelectionChange]); + + const _handleItemClick = useCallback((id: string, type: 'file' | 'folder', e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + const next = new Set(selectedItemIds); + if (next.has(id)) next.delete(id); else next.add(id); + _setSelection(next); + lastClickedIdRef.current = id; + return; + } + + if (e.shiftKey && lastClickedIdRef.current) { + const lastIdx = flatList.findIndex(i => i.id === lastClickedIdRef.current); + const currIdx = flatList.findIndex(i => i.id === id); + if (lastIdx >= 0 && currIdx >= 0) { + const [from, to] = lastIdx < currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx]; + const next = new Set(selectedItemIds); + for (let i = from; i <= to; i++) next.add(flatList[i].id); + _setSelection(next); + } + return; + } + + _setSelection(new Set([id])); + lastClickedIdRef.current = id; + if (type === 'folder') onSelect(id); + if (type === 'file') onFileSelect?.(id); + }, [selectedItemIds, flatList, _setSelection, onSelect, onFileSelect]); + + const _handleItemDragStart = useCallback((e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => { + const isInSelection = selectedItemIds.has(id) && selectedItemIds.size > 1; + + if (isInSelection) { + const items: TreeItem[] = []; + for (const selId of selectedItemIds) { + const item = flatList.find(i => i.id === selId); + if (item) items.push(item); + } + e.dataTransfer.setData('application/tree-items', JSON.stringify(items)); + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + if (fileIds.length > 0) { + e.dataTransfer.setData('application/file-ids', JSON.stringify(fileIds)); + } + } else { + e.dataTransfer.setData('application/tree-items', JSON.stringify([{ id, type, name }])); + if (type === 'file') { + e.dataTransfer.setData('application/file-id', id); + } else { + e.dataTransfer.setData('application/folder-id', id); + } + } + e.dataTransfer.effectAllowed = 'copyMove'; + }, [selectedItemIds, flatList]); + + const allFileIds = useMemo(() => { + const ids = new Set(); + for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id); + return ids; + }, [filesByFolder]); + + const allFolderIds = useMemo(() => { + const ids = new Set(); + const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } }; + _collect(tree); + return ids; + }, [tree]); + + const sel: SelectionCtx = useMemo(() => { + const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id)); + const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id)); + return { + selectedItemIds, + selectedFileIds: selFileIds, + selectedFolderIds: selFolderIds, + onItemClick: _handleItemClick, + onItemDragStart: _handleItemDragStart, + onRenameFile, + onDeleteFile, + onDeleteFiles, + onDeleteFolders, + }; + }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]); + + const _handleRootDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + setRootDropOver(false); + + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); + if (treeItemsJson) { + const items: TreeItem[] = JSON.parse(treeItemsJson); + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + const folderIds = items.filter(i => i.type === 'folder').map(i => i.id); + if (folderIds.length > 0 && onMoveFolders) { + await onMoveFolders(folderIds, null); + } else if (onMoveFolder) { + for (const fId of folderIds) await onMoveFolder(fId, null); + } + if (fileIds.length > 0 && onMoveFiles) { + await onMoveFiles(fileIds, null); + } else if (fileIds.length > 0 && onMoveFile) { + for (const fId of fileIds) await onMoveFile(fId, null); + } + return; + } + + const folderId = e.dataTransfer.getData('application/folder-id'); + const fileIdsJson = e.dataTransfer.getData('application/file-ids'); + const fileId = e.dataTransfer.getData('application/file-id'); + if (folderId && onMoveFolder) { + await onMoveFolder(folderId, null); + } else if (fileIdsJson && onMoveFiles) { + await onMoveFiles(JSON.parse(fileIdsJson), null); + } else if (fileId && onMoveFile) { + await onMoveFile(fileId, null); + } + }, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]); + + const rootClasses = [ + styles.treeNode, + selectedFolderId === null ? styles.selected : '', + rootDropOver ? styles.dropTarget : '', + ].filter(Boolean).join(' '); + + return ( +
+
{ onSelect(null); _setSelection(new Set()); }} + onDragOver={(e) => { e.preventDefault(); setRootDropOver(true); }} + onDragLeave={() => setRootDropOver(false)} + onDrop={_handleRootDrop} + > + + (Global) + + {onRefresh && ( + + )} + {onCreateFolder && ( + + )} + +
+
+ {tree.map((node) => ( + <_TreeNode + key={node.id} + node={node} + depth={1} + selectedFolderId={selectedFolderId} + expandedIds={expandedIds} + showFiles={showFiles} + filesByFolder={filesByFolder} + sel={sel} + onToggle={_handleToggle} + onSelect={onSelect} + onCreateFolder={onCreateFolder} + onRenameFolder={onRenameFolder} + onDeleteFolder={onDeleteFolder} + onMoveFolder={onMoveFolder} + onMoveFolders={onMoveFolders} + onMoveFile={onMoveFile} + onMoveFiles={onMoveFiles} + onDownloadFolder={onDownloadFolder} + /> + ))} + {rootFiles.map((file) => ( + <_FileItem key={file.id} file={file} sel={sel} /> + ))} +
+
+ ); +} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 3ba1764..f97d6d8 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -171,6 +171,8 @@ export interface FormGeneratorTableProps { groupRowData?: (groupKey: string, groupRows: T[]) => Record; groupDefaultExpanded?: boolean; groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode; + rowDraggable?: boolean; + onRowDragStart?: (e: React.DragEvent, row: T) => void; } export function FormGeneratorTable>({ @@ -208,7 +210,9 @@ export function FormGeneratorTable>({ groupRenderer: _groupRenderer, groupRowData, groupDefaultExpanded = true, - groupActions + groupActions, + rowDraggable = false, + onRowDragStart, }: FormGeneratorTableProps) { const { t, currentLanguage: contextLanguage } = useLanguage(); // When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected @@ -282,7 +286,7 @@ export function FormGeneratorTable>({ // Track if we've loaded from localStorage for this storage key const loadedStorageKeyRef = useRef(null); - // Check if backend pagination is supported (hookData has refetch that accepts params) + // Check if backend pagination is supported (hookData has refetch that accepts params). const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function'; // Debounce search term for backend calls @@ -1971,6 +1975,8 @@ export function FormGeneratorTable>({ key={`${groupKey}-row-${rowIndex}`} className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`} onClick={() => onRowClick?.(row, globalIndex)} + draggable={rowDraggable} + onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined} {...Object.fromEntries( Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value]) )} @@ -2084,6 +2090,8 @@ export function FormGeneratorTable>({ key={index} className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`} onClick={() => onRowClick?.(row, index)} + draggable={rowDraggable} + onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined} {...Object.fromEntries( Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value]) )} diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index e19cd0e..1b6f9e7 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -49,9 +49,12 @@ export const UserSection: React.FC = () => { } // Initialen fΓΌr Avatar - const initials = user.fullName - ? user.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) - : user.username.slice(0, 2).toUpperCase(); + const initials = (() => { + const name = user.fullName || user.username || ''; + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toLocaleUpperCase(); + return [...name.trim()].slice(0, 2).join('').toLocaleUpperCase() || '?'; + })(); return (
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/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx b/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx index 59031c8..41d9692 100644 --- a/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx +++ b/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx @@ -2,114 +2,37 @@ import React, { useMemo } from 'react'; import { WorkflowStatusProps, WorkflowStatusType } from './WorkflowStatusTypes'; import styles from './WorkflowStatus.module.css'; -// Helper function to extract workflow status and round from log message +const _STATUS_MAP: Record = { + success: 'completed', + completed: 'completed', + started: 'started', + running: 'started', + resumed: 'resumed', + stopped: 'stopped', + failed: 'failed', + error: 'failed', +}; + const extractWorkflowStatus = (logs: any[]): { status: WorkflowStatusType; round: number | null; timestamp: number } => { - // First, check for completion messages with success status (these take priority) - const completionMessages = logs.filter(log => { - const message = (log.message || '').toLowerCase(); + if (!logs.length) return { status: null, round: null, timestamp: 0 }; + + const sorted = [...logs].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); + + for (const log of sorted) { const logStatus = (log.status || '').toLowerCase(); - return (message.includes('fast path completed') || - message.includes('completed successfully')) && - logStatus === 'success'; - }); - - // If we have completion messages, use the latest one - if (completionMessages.length > 0) { - const latestCompletion = completionMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0]; - - // Try to extract round from completion message - let round: number | null = null; - const message = (latestCompletion.message || '').toLowerCase(); - const roundMatch = message.match(/\(?round\s+(\d+)\)?/i); - if (roundMatch) { - round = parseInt(roundMatch[1], 10); - } else { - // If no round in completion message, get round from latest workflow status message - const statusMessages = logs.filter(log => { - const msg = (log.message || '').toLowerCase(); - return msg.includes('workflow started') || msg.includes('workflow resumed'); - }); - if (statusMessages.length > 0) { - const latestWorkflowStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0]; - const workflowMessage = (latestWorkflowStatus.message || '').toLowerCase(); - const workflowRoundMatch = workflowMessage.match(/\(?round\s+(\d+)\)?/i); - if (workflowRoundMatch) { - round = parseInt(workflowRoundMatch[1], 10); - } - } + const mapped = _STATUS_MAP[logStatus]; + if (mapped) { + const roundMatch = (log.message || '').match(/\(?round\s+(\d+)\)?/i); + return { status: mapped, round: roundMatch ? parseInt(roundMatch[1], 10) : null, timestamp: log.timestamp || 0 }; } - - return { - status: 'completed', - round, - timestamp: latestCompletion.timestamp || 0 - }; } - // If no completion messages, look for workflow started/resumed/stopped messages - const statusMessages = logs.filter(log => { - const message = (log.message || '').toLowerCase(); - return message.includes('workflow started') || - message.includes('workflow resumed') || - message.includes('workflow stopped') || - message.includes('workflow failed') || - message.includes('workflow completed'); - }); - - if (statusMessages.length === 0) { - return { status: null, round: null, timestamp: 0 }; - } - - // Get the latest status message - const latestStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0]; - const message = (latestStatus.message || '').toLowerCase(); - - let status: WorkflowStatusType = null; - if (message.includes('started')) { - status = 'started'; - } else if (message.includes('resumed')) { - status = 'resumed'; - } else if (message.includes('stopped')) { - status = 'stopped'; - } else if (message.includes('failed')) { - status = 'failed'; - } else if (message.includes('completed')) { - status = 'completed'; - } - - // Extract round number from message (e.g., "round 4", "round 2", or "(round 4)") - const roundMatch = message.match(/\(?round\s+(\d+)\)?/i); - const round = roundMatch ? parseInt(roundMatch[1], 10) : null; - - return { - status, - round, - timestamp: latestStatus.timestamp || 0 - }; + return { status: null, round: null, timestamp: 0 }; }; -// Helper function to format bytes to KB or MB -const formatBytes = (bytes?: number): string => { - if (bytes === undefined || bytes === null) return '-'; - if (bytes === 0) return '0 B'; - const kb = bytes / 1024; - if (kb < 1024) { - return `${kb.toFixed(2)} KB`; - } - const mb = kb / 1024; - return `${mb.toFixed(2)} MB`; -}; - -// Helper function to format price -const formatPrice = (price?: number): string => { - if (price === undefined || price === null) return '-'; - return `$${price.toFixed(2)}`; -}; - -// Helper function to format processing time -const formatProcessingTime = (time?: number): string => { - if (time === undefined || time === null) return '-'; - return `${time.toFixed(2)}s`; +const _formatCurrency = (amount?: number): string => { + if (amount === undefined || amount === null) return '-'; + return `${amount.toFixed(2)} CHF`; }; const WorkflowStatus: React.FC = ({ @@ -122,40 +45,10 @@ const WorkflowStatus: React.FC = ({ }) => { // Use workflow status and round from API response, fallback to extracting from logs const workflowStatus = useMemo(() => { - // If we have status from API, use it if (workflowStatusFromApi) { - let status: WorkflowStatusType = null; - const statusLower = workflowStatusFromApi.toLowerCase(); - - if (statusLower === 'completed') { - status = 'completed'; - } else if (statusLower === 'running') { - // Check if it's started or resumed from logs - const startedResumedLogs = logs.filter(log => { - const message = (log.message || '').toLowerCase(); - return message.includes('workflow started') || message.includes('workflow resumed'); - }); - if (startedResumedLogs.length > 0) { - const latest = startedResumedLogs.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0]; - const message = (latest.message || '').toLowerCase(); - status = message.includes('resumed') ? 'resumed' : 'started'; - } else { - status = 'started'; - } - } else if (statusLower === 'stopped') { - status = 'stopped'; - } else if (statusLower === 'failed') { - status = 'failed'; - } - - return { - status, - round: currentRoundFromApi || null, - timestamp: Date.now() / 1000 // Use current time since we don't have timestamp from API - }; + const mapped = _STATUS_MAP[workflowStatusFromApi.toLowerCase()] || null; + return { status: mapped, round: currentRoundFromApi || null, timestamp: Date.now() / 1000 }; } - - // Fallback to extracting from logs return extractWorkflowStatus(logs); }, [workflowStatusFromApi, currentRoundFromApi, logs]); @@ -185,33 +78,13 @@ const WorkflowStatus: React.FC = ({ )}
- {/* Stats Display */} - {latestStats && ( + {/* Cost Display */} + {latestStats && latestStats.priceCHF !== undefined && (
- {latestStats.priceUsd !== undefined && ( -
- Price: - {formatPrice(latestStats.priceUsd)} -
- )} - {latestStats.processingTime !== undefined && ( -
- Time: - {formatProcessingTime(latestStats.processingTime)} -
- )} - {latestStats.bytesSent !== undefined && ( -
- Sent: - {formatBytes(latestStats.bytesSent)} -
- )} - {latestStats.bytesReceived !== undefined && ( -
- Received: - {formatBytes(latestStats.bytesReceived)} -
- )} +
+ Cost: + {_formatCurrency(latestStats.priceCHF)} +
)} diff --git a/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts b/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts index d83ca14..5275b9a 100644 --- a/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts +++ b/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts @@ -44,13 +44,10 @@ export interface WorkflowStatusProps { isRunning?: boolean; /** - * Latest statistics from the workflow (price, processing time, bytes sent/received) + * Latest cost from billing transactions (single source of truth) */ latestStats?: { - priceUsd?: number; - processingTime?: number; - bytesSent?: number; - bytesReceived?: number; + priceCHF?: number; } | null; } diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 902c588..21a8b40 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -110,12 +110,15 @@ export const PAGE_ICONS: Record = { 'feature.trustee': , 'feature.realestate': , 'feature.chatworkflow': , - 'feature.chatplayground': , - 'feature.codeeditor': , 'feature.automation': , 'page.feature.chatbot.conversations': , 'feature.chatbot': , 'feature.teamsbot': , + + // Feature pages - Workspace + 'page.feature.workspace.dashboard': , + 'page.feature.workspace.editor': , + 'feature.workspace': , }; // ============================================================================= diff --git a/src/contexts/FileContext.tsx b/src/contexts/FileContext.tsx index 4d20e64..1c53379 100644 --- a/src/contexts/FileContext.tsx +++ b/src/contexts/FileContext.tsx @@ -1,5 +1,9 @@ -import React, { createContext, useContext, useCallback } from 'react'; +import React, { createContext, useContext, useCallback, useState, useEffect } from 'react'; +import api from '../api'; import { useUserFiles, useFileOperations, UserFile } from '../hooks/useFiles'; +import type { FolderInfo } from '../api/fileApi'; + +export type { FolderInfo }; interface FileContextType { files: UserFile[]; @@ -14,6 +18,19 @@ interface FileContextType { deletingFiles: Set; previewingFiles: Set; downloadingFiles: Set; + folders: FolderInfo[]; + foldersLoading: boolean; + refreshFolders: () => Promise; + handleCreateFolder: (name: string, parentId: string | null) => Promise; + handleRenameFolder: (folderId: string, newName: string) => Promise; + handleDeleteFolder: (folderId: string) => Promise; + handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise; + handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise; + handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise; + handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise; + handleDownloadFolder: (folderId: string, folderName: string) => Promise; + expandedFolderIds: Set; + toggleFolderExpanded: (id: string) => void; } export const FileContext = createContext(undefined); @@ -31,45 +48,120 @@ export function FileProvider({ children }: { children: React.ReactNode }) { downloadingFiles } = useFileOperations(); - // Centralized file upload that updates the shared state + useEffect(() => { refetchFiles(); }, []); + + // ── Folder expanded state (persisted in localStorage) ─────────────────── + const _STORAGE_KEY = 'folderTree-expandedIds'; + const [expandedFolderIds, setExpandedFolderIds] = useState>(() => { + try { + const stored = localStorage.getItem(_STORAGE_KEY); + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch { return new Set(); } + }); + + const toggleFolderExpanded = useCallback((id: string) => { + setExpandedFolderIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + try { localStorage.setItem(_STORAGE_KEY, JSON.stringify([...next])); } catch {} + return next; + }); + }, []); + + // ── Folder state (single source of truth) ────────────────────────────── + const [folders, setFolders] = useState([]); + const [foldersLoading, setFoldersLoading] = useState(false); + + const refreshFolders = useCallback(async () => { + setFoldersLoading(true); + try { + const response = await api.get('/api/files/folders'); + const data = Array.isArray(response.data) ? response.data : []; + setFolders(data); + } catch (err) { + console.error('Failed to load folders:', err); + } finally { + setFoldersLoading(false); + } + }, []); + + useEffect(() => { refreshFolders(); }, [refreshFolders]); + + const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => { + await api.post('/api/files/folders', { name, parentId: parentId || null }); + await refreshFolders(); + }, [refreshFolders]); + + const handleRenameFolder = useCallback(async (folderId: string, newName: string) => { + await api.put(`/api/files/folders/${folderId}`, { name: newName }); + await refreshFolders(); + }, [refreshFolders]); + + const handleDeleteFolder = useCallback(async (folderId: string) => { + await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } }); + await refreshFolders(); + await refetchFiles(); + }, [refreshFolders, refetchFiles]); + + const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => { + await api.post(`/api/files/folders/${folderId}/move`, { targetParentId }); + await refreshFolders(); + }, [refreshFolders]); + + const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { + await api.post(`/api/files/${fileId}/move`, { targetFolderId }); + await refetchFiles(); + }, [refetchFiles]); + + const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { + await api.post('/api/files/batch-move', { fileIds, targetFolderId }); + await refetchFiles(); + }, [refetchFiles]); + + const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => { + await api.post('/api/files/batch-move', { folderIds, targetParentId }); + await refreshFolders(); + }, [refreshFolders]); + + const handleDownloadFolder = useCallback(async (folderId: string, folderName: string) => { + try { + const response = await api.get(`/api/files/folders/${folderId}/download`, { + responseType: 'blob', + }); + const url = window.URL.createObjectURL(response.data); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `${folderName}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error('Failed to download folder:', err); + } + }, []); + + // ── File operations ──────────────────────────────────────────────────── + const handleFileUpload = useCallback(async (file: File, workflowId?: string) => { const result = await hookHandleFileUpload(file, workflowId); - if (result.success && result.fileData) { - // The API response structure: { message, file: FileInfo, ... } - // The file data is nested in the 'file' property - const responseData = result.fileData; - const fileData = responseData.file || responseData; // Support both nested and direct structure - - if (!fileData || !fileData.id) { - console.error('File upload response missing file data:', responseData); - return result; - } - - // File will be added via refetch - // Refetch to ensure we have the latest data (this will update all consumers) await refetchFiles(); } - return result; }, [hookHandleFileUpload, refetchFiles]); - // Centralized file delete that updates the shared state const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => { const success = await hookHandleFileDelete(fileId, () => { removeFileOptimistically(fileId); onOptimisticDelete?.(); }); - if (success) { - // Refetch to ensure we have the latest data await refetchFiles(); } - return success; }, [hookHandleFileDelete, removeFileOptimistically, refetchFiles]); - // Expose refetch function const refetch = useCallback(async () => { await refetchFiles(); }, [refetchFiles]); @@ -86,12 +178,24 @@ export function FileProvider({ children }: { children: React.ReactNode }) { handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'], handleFileDownload: async (fileId: string, fileName: string) => { await handleFileDownload(fileId, fileName); - // Return void (ignore boolean return value) }, uploadingFile, deletingFiles, previewingFiles, - downloadingFiles + downloadingFiles, + folders, + foldersLoading, + refreshFolders, + handleCreateFolder, + handleRenameFolder, + handleDeleteFolder, + handleMoveFolder, + handleMoveFile, + handleMoveFiles, + handleMoveFolders, + handleDownloadFolder, + expandedFolderIds, + toggleFolderExpanded, }} > {children} @@ -106,4 +210,3 @@ export function useFileContext() { } return context; } - 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 9b1be84..0000000 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ /dev/null @@ -1,614 +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' | 'stat'; - item: WorkflowMessage | WorkflowLog | any; - 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<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null); - - // === REFS FOR SYNC ACCESS === - const statusRef = useRef('idle'); - const lastRenderedTimestampRef = useRef(null); - const processedStatIdsRef = useRef>(new Set()); - const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }); - - // === 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[]; stats: any[] }) => { - console.log('πŸ”„ Processing chat data:', { - messages: chatData.messages?.length || 0, - logs: chatData.logs?.length || 0, - stats: chatData.stats?.length || 0 - }); - - // Build unified timeline - const timeline: UnifiedChatDataItem[] = []; - - // Add messages - (chatData.messages || []).forEach((message: WorkflowMessage) => { - timeline.push({ - type: 'message', - item: message, - createdAt: message.publishedAt || message.timestamp || Date.now() - }); - }); - - // Add logs - (chatData.logs || []).forEach((log: any) => { - timeline.push({ - type: 'log', - item: log, - createdAt: log.timestamp || log.createdAt || Date.now() - }); - }); - - // Add stats - const rawStats = chatData.stats || []; - rawStats.forEach((stat: any) => { - timeline.push({ - type: 'stat', - item: stat, - createdAt: stat._createdAt || stat.createdAt || Date.now() - }); - }); - - // Sort chronologically - 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); - }); - - // === PROCESS STATS === - const statsItems = timeline.filter(item => item.type === 'stat'); - - if (statsItems.length > 0) { - let hasNewStats = false; - - statsItems.forEach(statItem => { - const statData = statItem.item; - const statId = statData?.id; - - if (statId && processedStatIdsRef.current.has(statId)) { - return; // Skip already processed - } - - if (statData) { - hasNewStats = true; - if (statId) { - processedStatIdsRef.current.add(statId); - } - - // Accumulate stats - const price = statData.priceCHF ?? statData.priceUsd ?? 0; - if (price > 0) cumulativeStatsRef.current.priceUsd += price; - if (statData.processingTime) cumulativeStatsRef.current.processingTime += statData.processingTime; - if (statData.bytesSent) cumulativeStatsRef.current.bytesSent += statData.bytesSent; - if (statData.bytesReceived) cumulativeStatsRef.current.bytesReceived += statData.bytesReceived; - } - }); - - if (hasNewStats) { - setLatestStats({ - priceUsd: cumulativeStatsRef.current.priceUsd, - processingTime: cumulativeStatsRef.current.processingTime, - bytesSent: cumulativeStatsRef.current.bytesSent, - bytesReceived: cumulativeStatsRef.current.bytesReceived - }); - } - } - }, [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, - stats: chatData.stats?.length || 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); - - // Reset refs - lastRenderedTimestampRef.current = null; - processedStatIdsRef.current.clear(); - cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; - hasRenderedLastMessageRef.current = false; - setHasRenderedLastMessage(false); - - pollingControllerRef.current.stopPolling(); - }, [updateWorkflowStatus]); - - // === SELECT/LOAD WORKFLOW === - const selectWorkflow = useCallback(async (workflowIdToSelect: string) => { - try { - console.log('πŸ“₯ Loading workflow:', workflowIdToSelect); - - // Reset state - setWorkflowId(workflowIdToSelect); - lastRenderedTimestampRef.current = null; - processedStatIdsRef.current.clear(); - cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; - hasRenderedLastMessageRef.current = false; - setHasRenderedLastMessage(false); - - // 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, - stats: chatData.stats?.length || 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/useCommcoach.ts b/src/hooks/useCommcoach.ts index b5f3bf0..1e14ed1 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -15,8 +15,7 @@ import { type CoachingContext, type CoachingSession, type CoachingMessage, type CoachingTask, type CoachingScore, type SSEEvent, } from '../api/commcoachApi'; - -export type TtsEvent = 'playing' | 'ended' | 'paused' | 'error'; +import { useTtsPlayback, type TtsEvent } from './useTtsPlayback'; export interface CommcoachHookReturn { contexts: CoachingContext[]; @@ -49,8 +48,11 @@ export interface CommcoachHookReturn { cancelSession: () => Promise; stopTts: () => void; + pauseTts: () => void; resumeTts: () => void; hasAudioToResume: () => boolean; + ttsIsPlaying: boolean; + ttsIsPaused: boolean; onTtsEventRef: MutableRefObject<((event: TtsEvent) => void) | null>; @@ -90,12 +92,21 @@ export function useCommcoach(): CommcoachHookReturn { const [actionLoading, setActionLoading] = useState(null); const isMountedRef = useRef(true); - const currentAudioRef = useRef(null); const abortControllerRef = useRef(null); const onTtsEventRef = useRef<((event: TtsEvent) => void) | null>(null); const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null); - useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); + const ttsPlayback = useTtsPlayback({ + onPlaying: () => { (window as any).__dlog?.('TTS-PLAYING'); onTtsEventRef.current?.('playing'); }, + onEnded: () => { (window as any).__dlog?.('TTS-ENDED'); onTtsEventRef.current?.('ended'); }, + onPaused: () => { (window as any).__dlog?.('TTS-PAUSED'); onTtsEventRef.current?.('paused'); }, + onError: () => { (window as any).__dlog?.('TTS-ERROR'); onTtsEventRef.current?.('error'); }, + }); + + useEffect(() => { + isMountedRef.current = true; + return () => { isMountedRef.current = false; }; + }, []); const refreshContexts = useCallback(async () => { if (!instanceId) return; @@ -111,54 +122,21 @@ export function useCommcoach(): CommcoachHookReturn { } }, [request, instanceId]); - const _emitTts = useCallback((event: TtsEvent) => { - (window as any).__dlog?.(`TTS-${event.toUpperCase()}`); - onTtsEventRef.current?.(event); - }, []); - - const _playTtsAudio = useCallback((audioB64: string) => { - if (!audioB64 || !isMountedRef.current) return; - if (currentAudioRef.current) { - currentAudioRef.current.pause(); - currentAudioRef.current = null; - } - try { - const audio = new Audio(`data:audio/mp3;base64,${audioB64}`); - currentAudioRef.current = audio; - audio.onended = () => { - currentAudioRef.current = null; - _emitTts('ended'); - }; - audio.play().then(() => { - _emitTts('playing'); - }).catch(() => { - _emitTts('error'); - }); - } catch { - _emitTts('error'); - } - }, [_emitTts]); - const stopTts = useCallback(() => { - if (currentAudioRef.current) { - currentAudioRef.current.pause(); - _emitTts('paused'); - } - }, [_emitTts]); + ttsPlayback.stop(); + }, [ttsPlayback]); + + const pauseTts = useCallback(() => { + ttsPlayback.pause(); + }, [ttsPlayback]); const resumeTts = useCallback(() => { - if (currentAudioRef.current && currentAudioRef.current.paused) { - currentAudioRef.current.play().then(() => { - _emitTts('playing'); - }).catch(() => { - _emitTts('error'); - }); - } - }, [_emitTts]); + ttsPlayback.resume(); + }, [ttsPlayback]); const hasAudioToResume = useCallback(() => { - return !!(currentAudioRef.current && currentAudioRef.current.paused && currentAudioRef.current.currentTime > 0); - }, []); + return ttsPlayback.isPaused; + }, [ttsPlayback]); const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => { if (!instanceId) return; @@ -196,7 +174,7 @@ export function useCommcoach(): CommcoachHookReturn { setMessages(eventData.messages); } } else if (eventType === 'ttsAudio' && eventData?.audio) { - _playTtsAudio(eventData.audio); + ttsPlayback.play(eventData.audio); } if (eventType === 'complete') setIsStreaming(false); }, @@ -210,7 +188,7 @@ export function useCommcoach(): CommcoachHookReturn { } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Kontexts'); } - }, [request, instanceId, _playTtsAudio]); + }, [request, instanceId, ttsPlayback.play]); const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => { if (!instanceId) return; @@ -298,7 +276,7 @@ export function useCommcoach(): CommcoachHookReturn { return [...prev, msg]; }); } else if (eventType === 'ttsAudio' && eventData?.audio) { - _playTtsAudio(eventData.audio); + ttsPlayback.play(eventData.audio); } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); } else if (eventType === 'taskCreated' && eventData) { @@ -333,7 +311,7 @@ export function useCommcoach(): CommcoachHookReturn { } finally { if (isMountedRef.current) setActionLoading(null); } - }, [instanceId, selectedContextId, _playTtsAudio]); + }, [instanceId, selectedContextId, ttsPlayback.play]); const sendMessage = useCallback(async (content: string) => { const normalizedContent = content.trim(); @@ -343,10 +321,7 @@ export function useCommcoach(): CommcoachHookReturn { const ac = new AbortController(); abortControllerRef.current = ac; - if (currentAudioRef.current) { - currentAudioRef.current.pause(); - currentAudioRef.current = null; - } + ttsPlayback.stop(); await _unlockAudioForTts(); setError(null); setIsStreaming(true); @@ -396,7 +371,7 @@ export function useCommcoach(): CommcoachHookReturn { }); } else if (eventType === 'ttsAudio' && eventData?.audio) { setError(null); - _playTtsAudio(eventData.audio); + ttsPlayback.play(eventData.audio); } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); } else if (eventType === 'taskCreated' && eventData) { @@ -433,14 +408,11 @@ export function useCommcoach(): CommcoachHookReturn { setIsStreaming(false); } } - }, [instanceId, session, _playTtsAudio]); + }, [instanceId, session, ttsPlayback.play]); const sendAudio = useCallback(async (audioBlob: Blob) => { if (!instanceId || !session) return; - if (currentAudioRef.current) { - currentAudioRef.current.pause(); - currentAudioRef.current = null; - } + ttsPlayback.stop(); await _unlockAudioForTts(); setError(null); setIsStreaming(true); @@ -474,7 +446,7 @@ export function useCommcoach(): CommcoachHookReturn { }); } else if (eventType === 'ttsAudio' && eventData?.audio) { setError(null); - _playTtsAudio(eventData.audio); + ttsPlayback.play(eventData.audio); } else if (eventType === 'taskCreated' && eventData) { setTasks(prev => [eventData, ...prev]); } else if (eventType === 'documentCreated' && eventData) { @@ -585,8 +557,10 @@ export function useCommcoach(): CommcoachHookReturn { error, inputValue, setInputValue, selectContext, createContext, archiveContext, startSession: startSessionCb, - sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb, - stopTts, resumeTts, hasAudioToResume, + sendMessage, sendAudio, + completeSession: completeSessionCb, cancelSession: cancelSessionCb, + stopTts, pauseTts, resumeTts, hasAudioToResume, + ttsIsPlaying: ttsPlayback.isPlaying, ttsIsPaused: ttsPlayback.isPaused, onTtsEventRef, actionLoading, toggleTaskStatus, addTask, removeTask, diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 9812b26..067874e 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -11,7 +11,8 @@ import { fetchFileById as fetchFileByIdApi, updateFile as updateFileApi, deleteFile as deleteFileApi, - deleteFiles as deleteFilesApi + deleteFiles as deleteFilesApi, + type FolderInfo, } from '../api/fileApi'; // File interfaces - exactly matching backend FileItem model @@ -479,7 +480,7 @@ export function useFileOperations() { * - Removed workflowId from FileItem creation in interfaceComponentObjects.py * - Upload should now work correctly */ - const handleFileUpload = async (file: globalThis.File, workflowId?: string) => { + const handleFileUpload = async (file: globalThis.File, workflowId?: string, featureInstanceId?: string) => { setUploadError(null); setUploadingFile(true); @@ -500,6 +501,9 @@ export function useFileOperations() { if (workflowId) { formData.append('workflowId', workflowId); } + if (featureInstanceId) { + formData.append('featureInstanceId', featureInstanceId); + } // FormData is now correctly configured for backend @@ -965,4 +969,87 @@ export function useFileOperations() { handleInlineUpdate, isLoading }; -} \ No newline at end of file +} + +// ── Folder management hook ────────────────────────────────────────────────── + +export function useFolders() { + const [folders, setFolders] = useState([]); + const [loading, setLoading] = useState(false); + const { showError } = useToast(); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const response = await api.get('/api/files/folders'); + const data = Array.isArray(response.data) ? response.data : []; + setFolders(data); + } catch (err) { + console.error('Failed to load folders:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => { + try { + await api.post('/api/files/folders', { name, parentId: parentId || null }); + await refresh(); + } catch (err: any) { + showError(err?.response?.data?.detail || err?.message || 'Folder creation failed'); + throw err; + } + }, [refresh, showError]); + + const handleRenameFolder = useCallback(async (folderId: string, newName: string) => { + try { + await api.put(`/api/files/folders/${folderId}`, { name: newName }); + await refresh(); + } catch (err: any) { + showError(err?.response?.data?.detail || err?.message || 'Rename failed'); + throw err; + } + }, [refresh, showError]); + + const handleDeleteFolder = useCallback(async (folderId: string) => { + try { + await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } }); + await refresh(); + } catch (err: any) { + showError(err?.response?.data?.detail || err?.message || 'Delete failed'); + throw err; + } + }, [refresh, showError]); + + const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => { + try { + await api.post(`/api/files/folders/${folderId}/move`, { targetParentId }); + await refresh(); + } catch (err: any) { + showError(err?.response?.data?.detail || err?.message || 'Move failed'); + throw err; + } + }, [refresh, showError]); + + const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { + try { + await api.post(`/api/files/${fileId}/move`, { targetFolderId }); + } catch (err: any) { + showError(err?.response?.data?.detail || err?.message || 'Move failed'); + throw err; + } + }, [showError]); + + return { + folders, + loading, + refresh, + handleCreateFolder, + handleRenameFolder, + handleDeleteFolder, + handleMoveFolder, + handleMoveFile, + }; +} \ No newline at end of file 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/useSpeechAudioCapture.ts b/src/hooks/useSpeechAudioCapture.ts new file mode 100644 index 0000000..1b6d7ac --- /dev/null +++ b/src/hooks/useSpeechAudioCapture.ts @@ -0,0 +1,198 @@ +/** + * useVoiceStream β€” single hook for mic capture + STT streaming. + * + * Starts MediaRecorder, opens a WebSocket to the generic STT endpoint, + * sends audio chunks, and receives interim/final transcripts from + * Google Streaming Recognition on the backend. + * + * No client-side VAD, no segmentation, no recorder restarts. + * Google handles silence detection and endpoint natively. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import api from '../api'; + +export type VoiceStreamStatus = 'idle' | 'connecting' | 'listening' | 'error'; + +export interface VoiceStreamCallbacks { + onInterim?: (text: string) => void; + onFinal?: (text: string) => void; + onStatusChange?: (status: VoiceStreamStatus) => void; + onError?: (error: unknown) => void; +} + +export interface VoiceStreamApi { + status: VoiceStreamStatus; + interimText: string; + start: (language?: string) => Promise; + stop: () => void; +} + +const _RECORDING_CHUNK_MS = 250; +const _MAX_RECONNECT_ATTEMPTS = 3; + +export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi { + const [status, setStatus] = useState('idle'); + const [interimText, setInterimText] = useState(''); + + const cbRef = useRef(callbacks); + cbRef.current = callbacks; + + const wsRef = useRef(null); + const recorderRef = useRef(null); + const streamRef = useRef(null); + const languageRef = useRef('de-DE'); + const stoppingRef = useRef(false); + const reconnectAttemptsRef = useRef(0); + + const _setStatus = useCallback((next: VoiceStreamStatus) => { + setStatus(next); + cbRef.current.onStatusChange?.(next); + }, []); + + const _pickMimeType = useCallback((): string => { + for (const mime of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']) { + try { if (MediaRecorder.isTypeSupported(mime)) return mime; } catch { /* skip */ } + } + throw new Error('No supported audio MIME type for MediaRecorder'); + }, []); + + const _closeWs = useCallback(() => { + const ws = wsRef.current; + if (!ws) return; + wsRef.current = null; + try { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'close' })); + } + ws.close(); + } catch { /* ignore */ } + }, []); + + const _stopRecorder = useCallback(() => { + const recorder = recorderRef.current; + if (recorder && recorder.state !== 'inactive') { + try { recorder.stop(); } catch { /* ignore */ } + } + recorderRef.current = null; + }, []); + + const _releaseDevices = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach(t => t.stop()); + streamRef.current = null; + } + }, []); + + const stop = useCallback(() => { + stoppingRef.current = true; + _stopRecorder(); + _closeWs(); + _releaseDevices(); + setInterimText(''); + _setStatus('idle'); + stoppingRef.current = false; + }, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]); + + const start = useCallback(async (language?: string) => { + if (status === 'listening' || status === 'connecting') return; + stoppingRef.current = false; + reconnectAttemptsRef.current = 0; + languageRef.current = language || 'de-DE'; + _setStatus('connecting'); + + try { + if (!streamRef.current) { + streamRef.current = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, + }); + } + + const tokenResp = await api.post('/voice-google/stt/token'); + const wsToken: string = tokenResp.data.wsToken; + + const baseURL = api.defaults.baseURL || window.location.origin; + const wsBase = baseURL.replace(/^http/i, 'ws'); + const wsUrl = `${wsBase}/voice-google/stt/stream?wsToken=${encodeURIComponent(wsToken)}`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (stoppingRef.current) { ws.close(); return; } + ws.send(JSON.stringify({ type: 'open', language: languageRef.current })); + + const mimeType = _pickMimeType(); + const recorder = new MediaRecorder(streamRef.current!, { mimeType }); + recorderRef.current = recorder; + + recorder.ondataavailable = (event: BlobEvent) => { + if (!event.data || event.data.size === 0) return; + if (ws.readyState !== WebSocket.OPEN) return; + const reader = new FileReader(); + reader.onloadend = () => { + if (ws.readyState !== WebSocket.OPEN) return; + const dataUrl = reader.result as string; + const b64 = dataUrl.split(',')[1]; + if (b64) ws.send(JSON.stringify({ type: 'audio', chunk: b64 })); + }; + reader.readAsDataURL(event.data); + }; + + recorder.start(_RECORDING_CHUNK_MS); + _setStatus('listening'); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'interim' && msg.text) { + setInterimText(msg.text); + cbRef.current.onInterim?.(msg.text); + } else if (msg.type === 'final' && msg.text) { + setInterimText(''); + cbRef.current.onFinal?.(msg.text); + } else if (msg.type === 'error') { + cbRef.current.onError?.(new Error(msg.message || msg.code || 'STT error')); + } else if (msg.type === 'reconnect_required') { + if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) { + reconnectAttemptsRef.current++; + _closeWs(); + start(languageRef.current).catch(() => {}); + } + } + } catch { /* ignore parse errors */ } + }; + + ws.onerror = () => { + if (!stoppingRef.current) { + cbRef.current.onError?.(new Error('WebSocket connection error')); + _setStatus('error'); + } + }; + + ws.onclose = () => { + if (!stoppingRef.current) { + _setStatus('idle'); + } + }; + + } catch (err) { + cbRef.current.onError?.(err); + _setStatus('error'); + _releaseDevices(); + throw err; + } + }, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices]); + + useEffect(() => { + return () => { + stoppingRef.current = true; + _stopRecorder(); + _closeWs(); + _releaseDevices(); + }; + }, [_stopRecorder, _closeWs, _releaseDevices]); + + return { status, interimText, start, stop }; +} diff --git a/src/hooks/useTtsPlayback.ts b/src/hooks/useTtsPlayback.ts new file mode 100644 index 0000000..ecb3edd --- /dev/null +++ b/src/hooks/useTtsPlayback.ts @@ -0,0 +1,79 @@ +/** + * useTtsPlayback β€” central hook for TTS audio playback. + * + * Plays base64-encoded audio (MP3), manages current playback state, + * emits lifecycle events. Used by all features (CommCoach, Workspace, etc.). + */ + +import { useCallback, useRef, useState } from 'react'; + +export type TtsEvent = 'playing' | 'paused' | 'ended' | 'error'; + +export interface TtsPlaybackCallbacks { + onPlaying?: () => void; + onPaused?: () => void; + onEnded?: () => void; + onError?: () => void; +} + +export interface TtsPlaybackApi { + isPlaying: boolean; + isPaused: boolean; + play: (base64Audio: string, format?: string) => void; + pause: () => void; + resume: () => void; + stop: () => void; +} + +export function useTtsPlayback(callbacks?: TtsPlaybackCallbacks): TtsPlaybackApi { + const [isPlaying, setIsPlaying] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const audioRef = useRef(null); + const cbRef = useRef(callbacks); + cbRef.current = callbacks; + + const _emit = useCallback((event: TtsEvent) => { + if (event === 'playing') { setIsPlaying(true); setIsPaused(false); cbRef.current?.onPlaying?.(); } + else if (event === 'paused') { setIsPaused(true); cbRef.current?.onPaused?.(); } + else if (event === 'ended') { setIsPlaying(false); setIsPaused(false); cbRef.current?.onEnded?.(); } + else if (event === 'error') { setIsPlaying(false); setIsPaused(false); cbRef.current?.onError?.(); } + }, []); + + const stop = useCallback(() => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + setIsPlaying(false); + setIsPaused(false); + }, []); + + const play = useCallback((base64Audio: string, format?: string) => { + if (!base64Audio) return; + stop(); + try { + const mimeType = format === 'wav' ? 'audio/wav' : 'audio/mp3'; + const audio = new Audio(`data:${mimeType};base64,${base64Audio}`); + audioRef.current = audio; + audio.onended = () => { audioRef.current = null; _emit('ended'); }; + audio.onpause = () => { if (audioRef.current === audio && audio.currentTime < audio.duration) _emit('paused'); }; + audio.play().then(() => _emit('playing')).catch(() => _emit('error')); + } catch { + _emit('error'); + } + }, [stop, _emit]); + + const pause = useCallback(() => { + if (audioRef.current && !audioRef.current.paused) { + audioRef.current.pause(); + } + }, []); + + const resume = useCallback(() => { + if (audioRef.current && audioRef.current.paused) { + audioRef.current.play().then(() => _emit('playing')).catch(() => _emit('error')); + } + }, [_emit]); + + return { isPlaying, isPaused, play, pause, resume, stop }; +} diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index cdc25dc..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; } @@ -276,12 +275,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?: } else if (attr.type === 'textarea') { fieldType = 'textarea'; } else if (attr.type === 'text') { - // Check if it should be textarea based on name - if (attr.name.toLowerCase().includes('description') || attr.name.toLowerCase().includes('note')) { - fieldType = 'textarea'; - } else { - fieldType = 'string'; - } + fieldType = (attr as any).multiline === true ? 'textarea' : 'string'; } // Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union // If needed, they should be handled via type casting: (attr as any).type === 'boolean' diff --git a/src/index.css b/src/index.css index f4923b5..082c1dc 100644 --- a/src/index.css +++ b/src/index.css @@ -20,4 +20,13 @@ html, body { margin: 0; padding: 0; font-family: var(--font-family, "DM Sans", sans-serif); -} \ No newline at end of file +} + +tr[data-highlighted="true"] { + animation: rowHighlight 2s ease-out; +} + +@keyframes rowHighlight { + 0% { background: rgba(25, 118, 210, 0.25); } + 100% { background: transparent; } +} \ No newline at end of file diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css index 3516819..25069b0 100644 --- a/src/layouts/MainLayout.module.css +++ b/src/layouts/MainLayout.module.css @@ -92,6 +92,8 @@ flex: 1; min-width: 0; min-height: 0; + position: relative; + --mobile-topbar-height: 0px; /* Let child components handle their own scrolling for sticky headers */ overflow: hidden; background: var(--bg-primary, #ffffff); @@ -228,6 +230,10 @@ border-bottom: 1px solid var(--border-color, #e0e0e0); } + .content { + --mobile-topbar-height: 57px; + } + .mobileBackdrop { display: block; position: fixed; diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 02ea999..7244eb0 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -10,8 +10,11 @@ import { Outlet, useLocation } from 'react-router-dom'; import { FeatureProvider, useFeatureStore } from '../stores/featureStore'; import { MandateNavigation } from '../components/Navigation/MandateNavigation'; import { UserSection } from '../components/Navigation/UserSection'; +import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'; import styles from './MainLayout.module.css'; +const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/; + // ============================================================================= // INNER LAYOUT (mit Zugriff auf Store) // ============================================================================= @@ -101,7 +104,12 @@ const MainLayoutInner: React.FC = () => { className={styles.mobileLogo} /> - + + + +
+ +
); 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 c0a29bc..56deae2 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -27,14 +27,13 @@ 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 import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; @@ -124,18 +123,15 @@ 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: { dashboard: TeamsbotDashboardView, @@ -199,6 +195,12 @@ export const FeatureViewPage: React.FC = ({ view }) => { return ; } + // Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level; + // other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering. + if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') { + return null; + } + // View-Komponente finden const featureViews = VIEW_COMPONENTS[featureCode]; if (!featureViews) { diff --git a/src/pages/Login.module.css b/src/pages/Login.module.css index 4394f61..a66ca81 100644 --- a/src/pages/Login.module.css +++ b/src/pages/Login.module.css @@ -1,16 +1,21 @@ .container { display: flex; - min-height: 100vh; - + min-height: 100dvh; font-family: "DM Sans", sans-serif; color: var(--color-bg); } +@supports not (min-height: 100dvh) { + .container { + min-height: 100vh; + } +} + .mainContent { flex: 1; display: flex; flex-direction: column; - padding: 3rem; + padding: 2.5rem 2rem; background-color: var(--color-bg); } @@ -18,39 +23,28 @@ display: flex; flex-direction: column; align-items: center; + width: 100%; flex: 1; } -.logoText { - - font-size: 35px; +.logo { display: flex; + justify-content: center; align-items: center; - letter-spacing: -0.5px; - font-weight: 200; + width: 100%; } -.logoPower { - color: var(--color-text); -} - -.logoOn { - color: var(--color-secondary); - font-weight: 700; -} - -.logo img { - height: 40px; +.logoImage { + height: 44px; + width: auto; + object-fit: contain; } .loginBox { - - background-color: var(--color-bg); - width: 25%; + width: min(100%, 460px); height: auto; - - margin-top: 5%; + margin-top: 2rem; padding: 2rem; border-radius: 25px; @@ -297,3 +291,41 @@ button:disabled { .passwordResetLink .textButton:hover { color: var(--color-secondary); } + +@media (max-width: 768px) { + .mainContent { + padding: 1rem; + } + + .logoImage { + height: 40px; + } + + .loginBox { + width: 100%; + margin-top: 1.25rem; + padding: 1.25rem; + border-radius: 20px; + } + + .registerLink { + flex-wrap: wrap; + text-align: center; + } +} + +@media (max-width: 420px) { + .mainContent { + padding: 0.75rem; + } + + .loginBox { + padding: 1rem; + border-radius: 16px; + } + + .input, + .button { + height: 48px; + } +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index d06bca0..f5673ee 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -108,10 +108,11 @@ function Login() {
-
- Power - On -
+ PowerOn
diff --git a/src/pages/PasswordResetRequest.module.css b/src/pages/PasswordResetRequest.module.css index b5fcf44..7b12a8b 100644 --- a/src/pages/PasswordResetRequest.module.css +++ b/src/pages/PasswordResetRequest.module.css @@ -1,16 +1,21 @@ .container { display: flex; - min-height: 100vh; - + min-height: 100dvh; font-family: "DM Sans", sans-serif; color: var(--color-bg); } +@supports not (min-height: 100dvh) { + .container { + min-height: 100vh; + } +} + .mainContent { flex: 1; display: flex; flex-direction: column; - padding: 3rem; + padding: 2.5rem 2rem; background-color: var(--color-bg); } @@ -18,39 +23,28 @@ display: flex; flex-direction: column; align-items: center; + width: 100%; flex: 1; } -.logoText { - - font-size: 35px; +.logo { display: flex; + justify-content: center; align-items: center; - letter-spacing: -0.5px; - font-weight: 200; + width: 100%; } -.logoPower { - color: var(--color-text); -} - -.logoOn { - color: var(--color-secondary); - font-weight: 700; -} - -.logo img { - height: 40px; +.logoImage { + height: 44px; + width: auto; + object-fit: contain; } .loginBox { - - background-color: var(--color-bg); - width: 25%; + width: min(100%, 460px); height: auto; - - margin-top: 5%; + margin-top: 2rem; padding: 2rem; border-radius: 25px; @@ -240,3 +234,41 @@ button:disabled { .infoMessage p { margin: 0; } + +@media (max-width: 768px) { + .mainContent { + padding: 1rem; + } + + .logoImage { + height: 40px; + } + + .loginBox { + width: 100%; + margin-top: 1.25rem; + padding: 1.25rem; + border-radius: 20px; + } + + .registerLink { + flex-wrap: wrap; + text-align: center; + } +} + +@media (max-width: 420px) { + .mainContent { + padding: 0.75rem; + } + + .loginBox { + padding: 1rem; + border-radius: 16px; + } + + .input, + .button { + height: 48px; + } +} diff --git a/src/pages/PasswordResetRequest.tsx b/src/pages/PasswordResetRequest.tsx index 6923655..74e4bc0 100644 --- a/src/pages/PasswordResetRequest.tsx +++ b/src/pages/PasswordResetRequest.tsx @@ -54,10 +54,11 @@ function PasswordResetRequest() {
-
- Power - On -
+ PowerOn
diff --git a/src/pages/Register.module.css b/src/pages/Register.module.css index e42373d..a0350a7 100644 --- a/src/pages/Register.module.css +++ b/src/pages/Register.module.css @@ -1,16 +1,21 @@ .container { display: flex; - min-height: 100vh; - + min-height: 100dvh; font-family: "DM Sans", sans-serif; color: var(--color-bg); } +@supports not (min-height: 100dvh) { + .container { + min-height: 100vh; + } +} + .mainContent { flex: 1; display: flex; flex-direction: column; - padding: 3rem; + padding: 2.5rem 2rem; background-color: var(--color-bg); } @@ -18,39 +23,28 @@ display: flex; flex-direction: column; align-items: center; + width: 100%; flex: 1; } -.logoText { - - font-size: 35px; +.logo { display: flex; + justify-content: center; align-items: center; - letter-spacing: -0.5px; - font-weight: 200; + width: 100%; } -.logoPower { - color: var(--color-text); -} - -.logoOn { - color: var(--color-secondary); - font-weight: 700; -} - -.logo img { - height: 40px; +.logoImage { + height: 44px; + width: auto; + object-fit: contain; } .loginBox { - - background-color: var(--color-bg); - width: 25%; + width: min(100%, 460px); height: auto; - - margin-top: 5%; + margin-top: 2rem; padding: 2rem; border-radius: 25px; @@ -273,3 +267,41 @@ button:disabled { .infoMessage p { margin: 0; } + +@media (max-width: 768px) { + .mainContent { + padding: 1rem; + } + + .logoImage { + height: 40px; + } + + .loginBox { + width: 100%; + margin-top: 1.25rem; + padding: 1.25rem; + border-radius: 20px; + } + + .registerLink { + flex-wrap: wrap; + text-align: center; + } +} + +@media (max-width: 420px) { + .mainContent { + padding: 0.75rem; + } + + .loginBox { + padding: 1rem; + border-radius: 16px; + } + + .input, + .button { + height: 48px; + } +} diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index a3c1403..8c71330 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -137,10 +137,11 @@ function Register() {
-
- Power - On -
+ PowerOn
diff --git a/src/pages/Reset.module.css b/src/pages/Reset.module.css index 401c409..18bda8b 100644 --- a/src/pages/Reset.module.css +++ b/src/pages/Reset.module.css @@ -1,16 +1,21 @@ .container { display: flex; - min-height: 100vh; - + min-height: 100dvh; font-family: "DM Sans", sans-serif; color: var(--color-bg); } +@supports not (min-height: 100dvh) { + .container { + min-height: 100vh; + } +} + .mainContent { flex: 1; display: flex; flex-direction: column; - padding: 3rem; + padding: 2.5rem 2rem; background-color: var(--color-bg); } @@ -18,39 +23,28 @@ display: flex; flex-direction: column; align-items: center; + width: 100%; flex: 1; } -.logoText { - - font-size: 35px; +.logo { display: flex; + justify-content: center; align-items: center; - letter-spacing: -0.5px; - font-weight: 200; + width: 100%; } -.logoPower { - color: var(--color-text); -} - -.logoOn { - color: var(--color-secondary); - font-weight: 700; -} - -.logo img { - height: 40px; +.logoImage { + height: 44px; + width: auto; + object-fit: contain; } .loginBox { - - background-color: var(--color-bg); - width: 25%; + width: min(100%, 460px); height: auto; - - margin-top: 5%; + margin-top: 2rem; padding: 2rem; border-radius: 25px; @@ -234,3 +228,41 @@ button:disabled { font-family: var(--font-family); margin-bottom: 10px; } + +@media (max-width: 768px) { + .mainContent { + padding: 1rem; + } + + .logoImage { + height: 40px; + } + + .loginBox { + width: 100%; + margin-top: 1.25rem; + padding: 1.25rem; + border-radius: 20px; + } + + .registerLink { + flex-wrap: wrap; + text-align: center; + } +} + +@media (max-width: 420px) { + .mainContent { + padding: 0.75rem; + } + + .loginBox { + padding: 1rem; + border-radius: 16px; + } + + .input, + .button { + height: 48px; + } +} diff --git a/src/pages/Reset.tsx b/src/pages/Reset.tsx index 617ef01..6f9eb1a 100644 --- a/src/pages/Reset.tsx +++ b/src/pages/Reset.tsx @@ -96,10 +96,11 @@ function Reset() {
-
- Power - On -
+ PowerOn
@@ -135,10 +136,11 @@ function Reset() {
-
- Power - On -
+ PowerOn
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/admin/Admin.module.css b/src/pages/admin/Admin.module.css index ccb0b9b..4fcc8df 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -88,6 +88,34 @@ border-color: var(--text-secondary); } +.googleButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #4285f4; + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} + +.googleButton:hover { + background: #3367d6; +} + +.googleButton:active { + transform: scale(0.98); +} + +.googleButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* Filter Section Styles */ .filterSection { display: flex; diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 0661fdb..9cd0b7e 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -221,7 +221,7 @@ export const ConnectionsPage: React.FC = () => { {canCreate && ( <> - {canCreate && ( - - )}
-
- {loading && (!files || files.length === 0) ? ( -
-
- Lade Dateien... -
- ) : !files || files.length === 0 ? ( -
- -

Keine Dateien vorhanden

-

- Laden Sie eine Datei hoch, um loszulegen. -

+ {/* Split-view container */} +
} + style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }} + > + {/* Left panel: FolderTree */} +
+ { + await handleDeleteFolder(folderId); + if (selectedFolderId === folderId) setSelectedFolderId(null); + await refetch(); + }} + onMoveFolder={handleMoveFolder} + onMoveFolders={handleMoveFolders} + onMoveFile={_handleMoveFilePage} + onMoveFiles={_handleMoveFiles} + onRenameFile={_handleRenameFile} + onDeleteFile={_handleDeleteTreeFile} + onDeleteFiles={_handleDeleteTreeFiles} + onDeleteFolders={_handleDeleteTreeFolders} + onDownloadFolder={handleDownloadFolder} + /> +
+ + {/* Resizable divider */} +
{ (e.target as HTMLElement).style.background = 'var(--color-border-hover, #bbb)'; }} + onMouseLeave={(e) => { if (!isDragging) (e.target as HTMLElement).style.background = 'transparent'; }} + /> + + {/* Right panel: File table */} +
+ {/* Toolbar above table */} +
+ {canCreate && ( - )}
- ) : ( - deletingFiles.has(row.id), - }] : []), - ]} - customActions={[ - { - id: 'download', - icon: , - onClick: handleDownload, - title: 'Herunterladen', - loading: (row: UserFile) => downloadingFiles.has(row.id), - }, - { - id: 'preview', - icon: , - onClick: handlePreview, - title: 'Vorschau', - loading: (row: UserFile) => previewingFiles.has(row.id), - }, - ]} - onDelete={handleDelete} - onDeleteMultiple={handleDeleteMultiple} - hookData={{ - refetch, - permissions, - pagination, - handleDelete: handleFileDelete, - handleInlineUpdate, - updateOptimistically: updateFileOptimistically, - }} - emptyMessage="Keine Dateien gefunden" - /> - )} + + {/* Table content */} +
+ {loading && (!files || files.length === 0) ? ( +
+
+ Lade Dateien... +
+ ) : filteredFiles.length === 0 ? ( +
+ +

+ {selectedFolderId ? 'Ordner ist leer' : 'Keine Dateien vorhanden'} +

+

+ {selectedFolderId + ? 'Verschieben Sie Dateien hierher oder laden Sie neue hoch.' + : 'Laden Sie eine Datei hoch, um loszulegen.'} +

+ {canCreate && ( + + )} +
+ ) : ( + setSelectedFiles(rows as UserFile[])} + rowDraggable={true} + onRowDragStart={_onRowDragStart} + getRowDataAttributes={(row: UserFile) => + ({ highlighted: row.id === highlightedFileId ? 'true' : 'false' }) + } + actionButtons={[ + ...(canUpdate ? [{ + type: 'edit' as const, + onAction: handleEditClick, + title: 'Bearbeiten', + }] : []), + ...(canDelete ? [{ + type: 'delete' as const, + title: 'LΓΆschen', + loading: (row: UserFile) => deletingFiles.has(row.id), + }] : []), + ]} + customActions={[ + { + id: 'download', + icon: , + onClick: handleDownload, + title: 'Herunterladen', + loading: (row: UserFile) => downloadingFiles.has(row.id), + }, + { + id: 'preview', + icon: , + onClick: handlePreview, + title: 'Vorschau', + loading: (row: UserFile) => previewingFiles.has(row.id), + }, + ]} + onDelete={handleDelete} + onDeleteMultiple={handleDeleteMultiple} + hookData={{ + refetch: _tableRefetch, + permissions, + handleDelete: handleFileDelete, + handleInlineUpdate, + updateOptimistically: updateFileOptimistically, + }} + emptyMessage="Keine Dateien gefunden" + /> + )} +
+
{/* Edit Modal */} @@ -331,12 +497,7 @@ export const FilesPage: React.FC = () => {
e.stopPropagation()}>

Datei bearbeiten

- +
{formAttributes.length === 0 ? ( diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index 7624f24..d25bfba 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -8,12 +8,16 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import { useAdminMandates } from '../../hooks/useMandates'; import styles from './Billing.module.css'; -const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500]; +const _formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); +}; // ============================================================================ // MANDATE SELECTOR @@ -195,18 +199,18 @@ interface CreditAdderProps { settings: BillingSettings | null; accounts: AccountSummary[]; users: MandateUserSummary[]; - onCreateCheckout: (userId: string | undefined, amount: number) => Promise<{ redirectUrl: string }>; + onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; } -const CreditAdder: React.FC = ({ settings, accounts, users, onCreateCheckout }) => { +const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { const [selectedUserId, setSelectedUserId] = useState(''); - const [amount, setAmount] = useState(10); + const [amount, setAmount] = useState(''); + const [description, setDescription] = useState('Manuelles Aufladen durch Admin'); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; - // Map accounts by userId for balance lookup const accountsByUserId = accounts .filter(acc => acc.accountType === 'USER') .reduce((map, acc) => { @@ -214,9 +218,10 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on return map; }, {} as Record); - const handleSubmit = async (e: React.FormEvent) => { + const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (amount <= 0) { + const numAmount = parseFloat(amount); + if (!numAmount || numAmount <= 0) { setMessage({ type: 'error', text: 'Betrag muss positiv sein' }); return; } @@ -225,24 +230,19 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on setMessage(null); try { - const { redirectUrl } = await onCreateCheckout(isPrepayUser ? selectedUserId : undefined, amount); - window.location.href = redirectUrl; + await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description); + setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` }); + setAmount(''); } catch (err: any) { setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' }); + } finally { setSaving(false); } }; - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('de-CH', { - style: 'currency', - currency: 'CHF' - }).format(amount); - }; - return (
-

Guthaben aufladen

+

Guthaben manuell aufladen

{message && (
@@ -250,7 +250,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on
)} -
+ {isPrepayUser && (
@@ -264,7 +264,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on {users.map((user) => { const account = accountsByUserId[user.id]; - const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)'; + const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)'; return (
@@ -370,18 +378,8 @@ const AccountsOverview: React.FC = ({ accounts, users, lo // ============================================================================ export const BillingAdmin: React.FC = () => { - const [searchParams, setSearchParams] = useSearchParams(); const [selectedMandateId, setSelectedMandateId] = useState(null); - const { settings, accounts, users, loading, saveSettings, createCheckout, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); - - const successParam = searchParams.get('success'); - const canceledParam = searchParams.get('canceled'); - - useEffect(() => { - if (successParam === 'true' && selectedMandateId) { - loadAccounts(); - } - }, [successParam, selectedMandateId, loadAccounts]); + const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); const handleMandateSelect = (mandateId: string) => { setSelectedMandateId(mandateId || null); @@ -392,19 +390,13 @@ export const BillingAdmin: React.FC = () => { await saveSettings(settingsUpdate); }, [selectedMandateId, saveSettings]); - const handleCreateCheckout = useCallback(async (userId: string | undefined, amount: number) => { + const _handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => { if (!selectedMandateId) throw new Error('Mandant nicht ausgewΓ€hlt'); - const result = await createCheckout({ userId, amount }); - if (!result) throw new Error('Checkout konnte nicht erstellt werden'); + const result = await addCredit({ userId, amount, description }); + if (!result) throw new Error('Gutschrift konnte nicht erstellt werden'); + await loadAccounts(); return result; - }, [selectedMandateId, createCheckout]); - - const clearStripeParams = useCallback(() => { - searchParams.delete('success'); - searchParams.delete('canceled'); - searchParams.delete('session_id'); - setSearchParams(searchParams, { replace: true }); - }, [searchParams, setSearchParams]); + }, [selectedMandateId, addCredit, loadAccounts]); return (
@@ -413,19 +405,6 @@ export const BillingAdmin: React.FC = () => {

Verwaltung von Abrechnungseinstellungen und Guthaben

- {successParam === 'true' && ( -
- Zahlung erfolgreich. Guthaben wird gutgeschrieben. - -
- )} - {canceledParam === 'true' && ( -
- Zahlung abgebrochen. - -
- )} -
{ settings={settings} accounts={accounts} users={users} - onCreateCheckout={handleCreateCheckout} + onAddCredit={_handleAddCredit} /> void; + checkoutLoading?: boolean; } -const BalanceCard: React.FC = ({ balance }) => { +const BalanceCard: React.FC = ({ balance, onCheckout, checkoutLoading }) => { + const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]); + const [showCheckout, setShowCheckout] = useState(false); + const _getBillingModelLabel = (model: string) => { switch (model) { case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; @@ -59,7 +69,11 @@ const BalanceCard: React.FC = ({ balance }) => { default: return model; } }; - + + const canTopUp = balance.billingModel === 'PREPAY_USER' + || balance.billingModel === 'PREPAY_MANDATE' + || balance.billingModel === 'CREDIT_POSTPAY'; + return (
@@ -74,6 +88,47 @@ const BalanceCard: React.FC = ({ balance }) => { Niedriges Guthaben
)} + {canTopUp && onCheckout && ( +
+ {!showCheckout ? ( + + ) : ( +
+ + + +
+ )} +
+ )}
); }; @@ -265,6 +320,10 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { export const BillingDataView: React.FC = () => { const [activeTab, setActiveTab] = useState('overview'); + const [searchParams, setSearchParams] = useSearchParams(); + const { request } = useApiRequest(); + const [checkoutLoading, setCheckoutLoading] = useState(false); + const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); // Scope filter: 'personal' | 'all' | mandateId const [selectedScope, setSelectedScope] = useState('personal'); @@ -272,9 +331,91 @@ export const BillingDataView: React.FC = () => { // Dashboard state (for Overview tab) const { balances, - loading: dashboardLoading, + loading: dashboardLoading, + refetch: refetchBalances, } = useBilling(); + const successParam = searchParams.get('success'); + const canceledParam = searchParams.get('canceled'); + const sessionIdParam = searchParams.get('session_id'); + + useEffect(() => { + let cancelled = false; + + const _confirmCheckoutIfNeeded = async () => { + if (successParam !== 'true') { + if (canceledParam === 'true' && !cancelled) { + setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' }); + } + return; + } + + if (!sessionIdParam) { + if (!cancelled) { + setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' }); + } + refetchBalances(); + return; + } + + try { + await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam }); + if (!cancelled) { + setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wurde verbucht.' }); + } + } catch (err: any) { + const detail = err?.response?.data?.detail; + if (!cancelled) { + setCheckoutMessage({ + type: 'error', + text: detail || 'Zahlung erfolgreich, aber Verbuchung konnte nicht bestaetigt werden.' + }); + } + } finally { + refetchBalances(); + } + }; + + _confirmCheckoutIfNeeded(); + + return () => { + cancelled = true; + }; + }, [successParam, canceledParam, sessionIdParam, refetchBalances]); + + const _clearStripeParams = useCallback(() => { + searchParams.delete('success'); + searchParams.delete('canceled'); + searchParams.delete('session_id'); + setSearchParams(searchParams, { replace: true }); + setCheckoutMessage(null); + }, [searchParams, setSearchParams]); + + const _handleCheckout = useCallback(async (mandateId: string, amount: number) => { + setCheckoutLoading(true); + setCheckoutMessage(null); + try { + const currentUser = getUserDataCache(); + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete('success'); + currentUrl.searchParams.delete('canceled'); + currentUrl.searchParams.delete('session_id'); + currentUrl.hash = ''; + const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`; + const result = await createCheckoutSession(request, mandateId, { + userId: currentUser?.id, + amount, + returnUrl, + }); + if (result?.redirectUrl) { + window.location.href = result.redirectUrl; + } + } catch (err: any) { + setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' }); + setCheckoutLoading(false); + } + }, [request]); + // All user balances (for admin overview cards) const [allUserBalances, setAllUserBalances] = useState([]); const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); @@ -475,6 +616,15 @@ export const BillingDataView: React.FC = () => { + {checkoutMessage && ( +
+ {checkoutMessage.text} + {(successParam || canceledParam) && ( + + )} +
+ )} + {/* ================================================================ */} {/* Tab: Übersicht (My Overview) */} {/* ================================================================ */} @@ -502,7 +652,12 @@ export const BillingDataView: React.FC = () => { ) : (
{filteredBalances.map((balance) => ( - + ))}
)} 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 - -
- )} -
- -
-
- - -
- -