From 574e3e1cfa27c966d0a747e7479a2642c4648d72 Mon Sep 17 00:00:00 2001 From: Ida Dittrich Date: Thu, 21 Aug 2025 10:25:53 +0200 Subject: [PATCH] smoother progress bar and message rendering --- .../DashboardChat/DashboardChatAreaInput.tsx | 21 +- .../DashboardChatAreaLogItem.tsx | 36 - .../DashboardChatAreaMessageList.tsx | 668 ++++-------------- .../DashboardChatMessages.module.css | 35 +- .../dashboardChatAreaProgressBar.ts | 276 ++++++++ .../DashboardChat/dashboardChatAreaTypes.ts | 41 +- .../DashboardChat/useWorkflowManager.ts | 95 ++- src/locales/de.ts | 10 +- src/locales/en.ts | 8 + src/locales/fr.ts | 9 + 10 files changed, 586 insertions(+), 613 deletions(-) create mode 100644 src/components/Dashboard/DashboardChat/dashboardChatAreaProgressBar.ts diff --git a/src/components/Dashboard/DashboardChat/DashboardChatAreaInput.tsx b/src/components/Dashboard/DashboardChat/DashboardChatAreaInput.tsx index c00fdea..b29da89 100644 --- a/src/components/Dashboard/DashboardChat/DashboardChatAreaInput.tsx +++ b/src/components/Dashboard/DashboardChat/DashboardChatAreaInput.tsx @@ -1,27 +1,12 @@ import React, { useState, useEffect, useRef } from 'react'; import FileAttachmentPopup from './FileAttachmentPopup'; -import { Prompt, WorkflowState, WorkflowActions } from './dashboardChatAreaTypes'; +import { Prompt, WorkflowState, WorkflowActions, InputAreaProps, AttachedFile } from './dashboardChatAreaTypes'; import { useLanguage } from '../../../contexts/LanguageContext'; import styles from './DashboardChatAreaStyles/DashboardChatAreaInput.module.css'; import sharedStyles from './DashboardChatAreaStyles/DashboardChat.module.css'; -interface InputAreaProps { - selectedPrompt?: Prompt | null; - workflowState: WorkflowState; - workflowActions: WorkflowActions; - attachedFiles?: AttachedFile[]; - onAttachedFilesChange?: (files: AttachedFile[]) => void; -} -interface AttachedFile { - id: number; - name: string; - size: number; - type: string; - fileData?: File; - objectUrl?: string; -} const InputArea: React.FC = ({ selectedPrompt, @@ -91,12 +76,8 @@ const InputArea: React.FC = ({ let success = false; if (workflowState.currentWorkflowId) { - // Continue existing workflow - console.log(`➡️ Continuing workflow ${workflowState.currentWorkflowId}`); success = await workflowActions.continueWorkflow(inputValue, fileIds); } else { - // Start new workflow - console.log('🚀 Starting new workflow'); const newWorkflowId = await workflowActions.startNewWorkflow(inputValue, fileIds); success = !!newWorkflowId; } diff --git a/src/components/Dashboard/DashboardChat/DashboardChatAreaLogItem.tsx b/src/components/Dashboard/DashboardChat/DashboardChatAreaLogItem.tsx index d1c8589..a3fb96c 100644 --- a/src/components/Dashboard/DashboardChat/DashboardChatAreaLogItem.tsx +++ b/src/components/Dashboard/DashboardChat/DashboardChatAreaLogItem.tsx @@ -106,42 +106,6 @@ const LogItem: React.FC = ({ log }) => { Progress: {log.progress}% )} - - {(log.details || log.performance) && ( - <> - - - {showDetails && ( -
- {log.details && ( -
- Details: -
{JSON.stringify(log.details, null, 2)}
-
- )} - {log.performance && ( -
- Performance: -
{JSON.stringify(log.performance, null, 2)}
-
- )} -
- )} - - )} ); }; diff --git a/src/components/Dashboard/DashboardChat/DashboardChatAreaMessageList.tsx b/src/components/Dashboard/DashboardChat/DashboardChatAreaMessageList.tsx index 7c7d1d2..e78269e 100644 --- a/src/components/Dashboard/DashboardChat/DashboardChatAreaMessageList.tsx +++ b/src/components/Dashboard/DashboardChat/DashboardChatAreaMessageList.tsx @@ -1,480 +1,96 @@ import React from 'react'; +import { MessageListProps } from './dashboardChatAreaTypes'; import { useApiRequest } from '../../../hooks/useApi'; import MessageItem from './DashboardChatAreaMessageItem'; import LogItem from './DashboardChatAreaLogItem'; -import { WorkflowMessage, Document, WorkflowState, WorkflowLog } from './dashboardChatAreaTypes'; +import { + calculateWorkflowProgress, + mergeMessagesAndLogs, + transformWorkflowMessage +} from './dashboardChatAreaProgressBar'; import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css'; +import { IoIosArrowDown, IoIosChatbubbles } from 'react-icons/io'; +import { useLanguage } from '../../../contexts/LanguageContext'; -// Helper function to parse task and action progress from log messages -const parseLogProgress = (logMessage: string): { - taskNumber?: number; - totalTasks?: number; - actionNumber?: number; - totalActions?: number; - isCompleted?: boolean; - type: 'task_start' | 'action_start' | 'action_complete' | 'task_complete' | 'unknown'; -} => { - const message = logMessage.trim(); - - // Pattern: "Executing task X/Y" - const taskStartPattern = /^Executing task (\d+)\/(\d+)$/i; - const taskStartMatch = message.match(taskStartPattern); - if (taskStartMatch) { - return { - taskNumber: parseInt(taskStartMatch[1]), - totalTasks: parseInt(taskStartMatch[2]), - type: 'task_start' - }; - } - - // Pattern: "Task X - Starting action Y/Z" - const actionStartPattern = /^Task (\d+) - Starting action (\d+)\/(\d+)$/i; - const actionStartMatch = message.match(actionStartPattern); - if (actionStartMatch) { - return { - taskNumber: parseInt(actionStartMatch[1]), - actionNumber: parseInt(actionStartMatch[2]), - totalActions: parseInt(actionStartMatch[3]), - type: 'action_start' - }; - } - - // Pattern: "Task X - Action Y/Z completed" or "✅ Task X - Action Y/Z completed" - const actionCompletePattern = /^(?:✅\s+)?Task (\d+) - Action (\d+)\/(\d+) completed$/i; - const actionCompleteMatch = message.match(actionCompletePattern); - if (actionCompleteMatch) { - return { - taskNumber: parseInt(actionCompleteMatch[1]), - actionNumber: parseInt(actionCompleteMatch[2]), - totalActions: parseInt(actionCompleteMatch[3]), - isCompleted: true, - type: 'action_complete' - }; - } - - // Pattern: "Task X/Y completed" or "🎯 Task X/Y completed" - const taskCompletePattern = /^(?:🎯\s+)?Task (\d+)\/(\d+) completed$/i; - const taskCompleteMatch = message.match(taskCompletePattern); - if (taskCompleteMatch) { - return { - taskNumber: parseInt(taskCompleteMatch[1]), - totalTasks: parseInt(taskCompleteMatch[2]), - isCompleted: true, - type: 'task_complete' - }; - } - - return { type: 'unknown' }; -}; -// Helper function to analyze workflow progress from messages and logs -const analyzeWorkflowProgressFromMessages = (messages: any[], logs: any[] = []): { - current: number; - total: number; - percentage: number; - isLoading: boolean; - taskBreakdown?: string; -} | null => { - console.log('🚨 analyzeWorkflowProgressFromMessages called:', { - messagesLength: messages.length, - logsLength: logs.length, - logMessages: logs.map(l => l.message) - }); - - if (messages.length === 0 && logs.length === 0) return null; - - // Check if the last message is from user (indicating we're waiting for assistant response) - const lastMessage = messages[messages.length - 1]; - const isWaitingForAssistant = lastMessage?.role === 'user'; - - // If we're waiting for assistant response, show loading state - if (isWaitingForAssistant) { - return { - current: 0, - total: 0, - percentage: 0, - isLoading: true - }; - } - - // Parse logs first (they contain the structure info) - let totalTasks = 0; - const taskActionCounts: { [taskNumber: number]: { total: number; completedActions: Set } } = {}; - - // Parse logs for structure information - logs.forEach((log) => { - if (!log.message) return; - - const content = log.message.trim(); - - // Debug: log message content for analysis - if (content.includes('✅') || content.includes('🚀') || content.includes('⚡') || content.includes('🎯') || content.includes('Executing task') || content.includes('Starting action')) { - console.log('📊 Progress: Analyzing log content:', content); - } - - // Look for task execution patterns: "Executing task X/Y" - const taskExecMatch = content.match(/Executing task (\d+)\/(\d+)/i); - if (taskExecMatch) { - const totalTasksFound = parseInt(taskExecMatch[2]); - if (totalTasksFound > totalTasks) { - totalTasks = totalTasksFound; - } - console.log('📊 Found total tasks:', totalTasksFound); - } - - // Look for action start patterns: "Task X - Starting action Y/Z" - const actionStartMatch = content.match(/Task (\d+) - Starting action (\d+)\/(\d+)/i); - if (actionStartMatch) { - const taskNumber = parseInt(actionStartMatch[1]); - const totalActions = parseInt(actionStartMatch[3]); - if (!taskActionCounts[taskNumber]) { - taskActionCounts[taskNumber] = { total: 0, completedActions: new Set() }; - } - if (totalActions > taskActionCounts[taskNumber].total) { - taskActionCounts[taskNumber].total = totalActions; - } - console.log(`📊 Task ${taskNumber} has ${totalActions} actions`); - } - - // Look for action completion patterns: "✅ Task X - Action Y/Z completed" - const actionCompleteMatch = content.match(/✅\s+Task (\d+) - Action (\d+)\/(\d+) completed/i); - if (actionCompleteMatch) { - const taskNumber = parseInt(actionCompleteMatch[1]); - const actionNumber = parseInt(actionCompleteMatch[2]); - const totalActions = parseInt(actionCompleteMatch[3]); - - if (!taskActionCounts[taskNumber]) { - taskActionCounts[taskNumber] = { total: totalActions, completedActions: new Set() }; - } - // Add this specific action to the completed set - taskActionCounts[taskNumber].completedActions.add(actionNumber); - // Update total if needed - if (totalActions > taskActionCounts[taskNumber].total) { - taskActionCounts[taskNumber].total = totalActions; - } - console.log(`📊 Task ${taskNumber} action ${actionNumber} completed`); - } - - // Look for task completion patterns: "🎯 Task X/Y completed" - const taskCompleteMatch = content.match(/🎯\s+Task (\d+)\/(\d+) completed/i); - if (taskCompleteMatch) { - const taskNumber = parseInt(taskCompleteMatch[1]); - const totalTasksFound = parseInt(taskCompleteMatch[2]); - if (totalTasksFound > totalTasks) { - totalTasks = totalTasksFound; - } - // Mark all actions for this task as completed - if (taskActionCounts[taskNumber]) { - const task = taskActionCounts[taskNumber]; - for (let i = 1; i <= task.total; i++) { - task.completedActions.add(i); - } - } - console.log(`📊 Task ${taskNumber} completed (all actions)`); - } - }); - - // Also parse assistant messages for any additional patterns - messages.forEach((message) => { - if (message.role !== 'assistant' || !message.content) return; - - const content = message.content.trim(); - - // Look for action completion in messages: "✅ Task X - Action document.generateReport completed" - const actionCompleteMatch = content.match(/✅\s+Task (\d+) - Action\s+(?:(\d+)\/(\d+)|.*completed)/i); - if (actionCompleteMatch) { - const taskNumber = parseInt(actionCompleteMatch[1]); - - // If we have the action number format (e.g., "1/1") - if (actionCompleteMatch[2] && actionCompleteMatch[3]) { - const actionNumber = parseInt(actionCompleteMatch[2]); - const totalActions = parseInt(actionCompleteMatch[3]); - - if (!taskActionCounts[taskNumber]) { - taskActionCounts[taskNumber] = { total: totalActions, completedActions: new Set() }; - } - taskActionCounts[taskNumber].completedActions.add(actionNumber); - if (totalActions > taskActionCounts[taskNumber].total) { - taskActionCounts[taskNumber].total = totalActions; - } - } else { - // If it's a named action without numbers, treat it as action 1/1 for this task - if (!taskActionCounts[taskNumber]) { - taskActionCounts[taskNumber] = { total: 1, completedActions: new Set() }; - } - taskActionCounts[taskNumber].completedActions.add(1); - if (taskActionCounts[taskNumber].total === 0) { - taskActionCounts[taskNumber].total = 1; - } - } - console.log(`📊 Message: Task ${taskNumber} action completed`); - } - }); - - // Calculate completed tasks (a task is complete when all its actions are done) - let completedTasks = 0; - const taskStatuses = []; - - // Check each task's completion status - for (let taskNum = 1; taskNum <= totalTasks; taskNum++) { - const task = taskActionCounts[taskNum]; - if (task) { - const isTaskComplete = task.total > 0 && task.completedActions.size === task.total; - if (isTaskComplete) { - completedTasks++; - } - taskStatuses.push(`Task ${taskNum}: ${task.completedActions.size}/${task.total} actions`); - } else { - taskStatuses.push(`Task ${taskNum}: Not started`); - } - } - - console.log('📊 Final calculation (task-based):', { - totalTasks, - completedTasks, - taskActionCounts: Object.fromEntries( - Object.entries(taskActionCounts).map(([taskNum, task]) => [ - taskNum, - { - total: task.total, - completed: task.completedActions.size, - completedActions: Array.from(task.completedActions), - isComplete: task.total > 0 && task.completedActions.size === task.total - } - ]) - ) - }); - - // Create task breakdown string - const taskBreakdown = taskStatuses.length > 0 ? taskStatuses.join(', ') : undefined; - - // If we have total tasks, always use task-based progress - if (totalTasks > 0) { - const percentage = Math.round((completedTasks / totalTasks) * 100); - console.log(`📊 Task-based progress: ${completedTasks}/${totalTasks} tasks (${percentage}%)`); - - return { - current: completedTasks, - total: totalTasks, - percentage: percentage, - isLoading: false, - taskBreakdown: taskBreakdown || `${completedTasks}/${totalTasks} tasks completed` - }; - } - - return null; -}; - -// Helper function to safely parse timestamps -const safeParseDate = (timestamp: any, fallback: number = Date.now()): Date => { - if (!timestamp) { - console.warn(`⚠️ No timestamp provided, using fallback:`, { fallback }); - return new Date(fallback); - } - - // Handle different timestamp formats - let dateToTry = timestamp; - - // If it's a number, check if it's in seconds or milliseconds - if (typeof timestamp === 'number') { - // If it's a 10-digit number, it's likely seconds since epoch - if (timestamp < 10000000000) { - dateToTry = timestamp * 1000; // Convert seconds to milliseconds - } else { - dateToTry = timestamp; // Already in milliseconds - } - } - // If it's a string that looks like a number, parse it and handle seconds/milliseconds - else if (typeof timestamp === 'string' && /^\d+$/.test(timestamp)) { - const numericTimestamp = parseInt(timestamp); - // If it's a 10-digit number, it's likely seconds since epoch - if (numericTimestamp < 10000000000) { - dateToTry = numericTimestamp * 1000; // Convert seconds to milliseconds - } else { - dateToTry = numericTimestamp; // Already in milliseconds - } - } - // If it's already a Date object - else if (timestamp instanceof Date) { - dateToTry = timestamp; - } - - // Try to parse the timestamp - const date = new Date(dateToTry); - - // Check if the date is valid - if (isNaN(date.getTime())) { - console.warn(`⚠️ Invalid timestamp detected:`, { - originalTimestamp: timestamp, - type: typeof timestamp, - processedTimestamp: dateToTry, - fallback - }); - return new Date(fallback); - } - - - return date; -}; - -// Helper function to merge and sort messages and logs by timestamp -const mergeMessagesAndLogs = (messages: any[], logs: WorkflowLog[]): Array<{type: 'message' | 'log', item: any, timestamp: Date}> => { - const combined: Array<{type: 'message' | 'log', item: any, timestamp: Date}> = []; - - - - // Add messages - messages.forEach((message, index) => { - const rawTimestamp = message.timestamp || message.publishedAt; - - // Use current time minus index for fallback to maintain order - const fallbackTime = Date.now() - ((messages.length + logs.length) - index) * 1000; - const timestamp = safeParseDate(rawTimestamp, fallbackTime); - - combined.push({ - type: 'message', - item: message, - timestamp - }); - }); - - // Add logs - logs.forEach((log, index) => { - - // Use current time minus index for fallback to maintain order - const fallbackTime = Date.now() - ((logs.length) - index) * 1000; - const timestamp = safeParseDate(log.timestamp, fallbackTime); - - combined.push({ - type: 'log', - item: log, - timestamp - }); - }); - - // Sort by timestamp (chronological order) - combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - - - return combined; -}; - -interface MessageListProps { - workflowState: WorkflowState; - onFilePreview?: (file: any) => void; -} - -// Helper function to transform WorkflowMessage to display Message -const transformWorkflowMessage = async (workflowMessage: WorkflowMessage, request: any): Promise => { - let documents: Document[] = []; - - // Check if we have documents directly from the API or need to fetch via fileIds - if (workflowMessage.documents && workflowMessage.documents.length > 0) { - // Use documents directly from the API - documents = workflowMessage.documents.map(doc => ({ - id: doc.id || doc.fileId, - fileId: typeof doc.fileId === 'string' ? parseInt(doc.fileId) : doc.fileId, - name: doc.filename, - ext: doc.filename.split('.').pop() || 'unknown', - type: doc.mimeType, - size: doc.fileSize, - downloadUrl: `/api/workflows/files/${doc.fileId}/download` - })); - } else if (workflowMessage.fileIds && workflowMessage.fileIds.length > 0) { - // Fallback to legacy fileIds approach - - const documentPromises = workflowMessage.fileIds.map(async (fileId, fileIndex) => { - try { - console.log(`📁 Fetching metadata for file ${fileIndex + 1}/${workflowMessage.fileIds!.length}: ${fileId}`); - const response = await request({ - url: `/api/workflows/files/${fileId}/preview`, - method: 'get' - }); - - const document: Document = { - id: fileId.toString(), - fileId: fileId, - name: response.name || response.fileName || `File_${fileId}`, - ext: response.extension || response.ext || (response.name ? response.name.split('.').pop() : 'txt'), - type: response.mimeType || response.type || 'application/octet-stream', - size: response.size || 0, - downloadUrl: response.downloadUrl || response.url - }; - - console.log(`✅ File ${fileId} metadata processed:`, document.name); - return document; - } catch (error) { - console.error(`❌ Failed to fetch metadata for file ${fileId}:`, error); - // Return a fallback object for failed requests - return { - id: fileId.toString(), - fileId: fileId, - name: `File_${fileId}`, - ext: 'unknown', - type: 'application/octet-stream', - size: 0 - }; - } - }); - - documents = await Promise.all(documentPromises); - } - - // Try different possible field names for content (API uses 'message', some legacy might use 'content') - const possibleContent = workflowMessage.message || - workflowMessage.content || - (workflowMessage as any).text || - (workflowMessage as any).body || - ''; - - const transformedMessage = { - id: workflowMessage.id, - role: workflowMessage.role, - agentName: workflowMessage.role === 'user' ? 'You' : 'Assistant', - content: possibleContent, - timestamp: workflowMessage.publishedAt || workflowMessage.timestamp, - documents: documents - }; - - return transformedMessage; -}; const MessageList: React.FC = ({ workflowState, onFilePreview }) => { - - + const { t } = useLanguage(); const { request } = useApiRequest(); const [transformedMessages, setTransformedMessages] = React.useState([]); const [isTransforming, setIsTransforming] = React.useState(false); const scrollContainerRef = React.useRef(null); const [isUserScrolledUp, setIsUserScrolledUp] = React.useState(false); const lastMessageCountRef = React.useRef(0); - const [workflowProgress, setWorkflowProgress] = React.useState<{ - current: number; - total: number; - percentage: number; - isLoading: boolean; - taskBreakdown?: string; - } | null>(null); + const [showProgressBar, setShowProgressBar] = React.useState(false); + const [progressText, setProgressText] = React.useState(''); + const [progressPercentage, setProgressPercentage] = React.useState(0); + const [maxCompletedTasks, setMaxCompletedTasks] = React.useState(0); - // Calculate workflow progress when messages or logs change + // ONE single effect to handle all progress logic React.useEffect(() => { - console.log('🔄 Progress calculation triggered:', { - messagesCount: transformedMessages.length, - logsCount: workflowState.logs?.length || 0, - logs: workflowState.logs?.map(l => l.message) || [] - }); + const pendingMessages = workflowState.pendingMessages || []; + const logs = workflowState.logs || []; - const progress = analyzeWorkflowProgressFromMessages(transformedMessages, workflowState.logs || []); + // Show progress bar if user sent message or logs exist + if ((pendingMessages.length > 0 || logs.length > 0) && !showProgressBar) { + setShowProgressBar(true); + setProgressPercentage(0); + setMaxCompletedTasks(0); + } - console.log('📊 Progress result:', progress); + // Update text based on current state + if (logs.length === 0 && pendingMessages.length > 0) { + setProgressText(t('chat.messages.loading_progress')); + return; + } - setWorkflowProgress(progress); - }, [transformedMessages.length, JSON.stringify(transformedMessages.map(m => m.content)), workflowState.logs?.length, JSON.stringify(workflowState.logs?.map(l => l.message))]); + if (logs.length > 0) { + let totalTasks = 0; + let completedTasks = 0; + + logs.forEach(log => { + const message = log.message || ''; + + const taskStartMatch = message.match(/Executing task (\d+)\/(\d+)/i); + if (taskStartMatch) { + totalTasks = Math.max(totalTasks, parseInt(taskStartMatch[2])); + } + + const taskCompleteMatch = message.match(/✅.*Task (\d+).*completed/i); + if (taskCompleteMatch) { + completedTasks = Math.max(completedTasks, parseInt(taskCompleteMatch[1])); + } + }); + + if (totalTasks > 0) { + const currentPercentage = Math.round((completedTasks / totalTasks) * 100); + setProgressText(`${completedTasks}/${totalTasks} ${t('chat.messages.tasks')} (${currentPercentage}%)`); + + // ONLY update percentage if we completed more tasks than before + if (completedTasks > maxCompletedTasks) { + setMaxCompletedTasks(completedTasks); + setProgressPercentage(currentPercentage); + } + } else { + setProgressText(`${t('chat.messages.analyzing_workflow')} (${logs.length} ${t('chat.messages.logs')})`); + } + } + }, [workflowState.pendingMessages, workflowState.logs, showProgressBar, maxCompletedTasks, t]); + + // Clear progress bar when workflow changes + React.useEffect(() => { + if (!workflowState.currentWorkflowId) { + setShowProgressBar(false); + setProgressText(''); + setProgressPercentage(0); + setMaxCompletedTasks(0); + } + }, [workflowState.currentWorkflowId]); - // Create merged timeline of messages and logs const timeline = React.useMemo(() => { return mergeMessagesAndLogs(transformedMessages, workflowState.logs || []); }, [transformedMessages, workflowState.logs]); @@ -482,23 +98,33 @@ const MessageList: React.FC = ({ // Transform messages when workflow messages change React.useEffect(() => { const transformMessages = async () => { - if (!workflowState.messages || workflowState.messages.length === 0) { + // Use pending messages for immediate display, backend messages for confirmed content + // Since the workflow manager already filters out duplicate user messages, + // we need to combine them properly: pending first, then backend messages + const pendingMessages = workflowState.pendingMessages || []; + const backendMessages = workflowState.messages || []; + + + + // Create a simple combined list - pending messages will be user messages, + // backend messages will be mostly assistant messages (user messages filtered out) + const allMessages = [...pendingMessages, ...backendMessages]; + + + + if (allMessages.length === 0) { setTransformedMessages([]); return; } setIsTransforming(true); - try { const transformed = await Promise.all( - workflowState.messages.map(async (msg: WorkflowMessage) => { - - return await transformWorkflowMessage(msg, request); - }) + allMessages.map(msg => transformWorkflowMessage(msg, request)) ); setTransformedMessages(transformed); } catch (error) { - console.error('❌ Error transforming messages:', error); + console.error('Error transforming messages:', error); setTransformedMessages([]); } finally { setIsTransforming(false); @@ -506,23 +132,18 @@ const MessageList: React.FC = ({ }; transformMessages(); - }, [workflowState.messages, request]); + }, [workflowState.messages, workflowState.pendingMessages, request]); - // Check if user is scrolled near the bottom + // Scroll handling const checkScrollPosition = React.useCallback(() => { const container = scrollContainerRef.current; if (!container) return; const { scrollTop, scrollHeight, clientHeight } = container; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - - // Consider "near bottom" if within 100px of the bottom - const isNearBottom = distanceFromBottom < 100; - setIsUserScrolledUp(!isNearBottom); - + setIsUserScrolledUp(distanceFromBottom > 100); }, []); - // Scroll to bottom function const scrollToBottom = React.useCallback(() => { const container = scrollContainerRef.current; if (container) { @@ -530,34 +151,27 @@ const MessageList: React.FC = ({ } }, []); - // Auto-scroll when new items arrive (only if user is near bottom) + // Auto-scroll on new messages React.useEffect(() => { - const currentTimelineCount = timeline.length; - const hadItems = lastMessageCountRef.current > 0; - const hasNewItems = currentTimelineCount > lastMessageCountRef.current; + const currentCount = timeline.length; + const hasNewItems = currentCount > lastMessageCountRef.current; - if (hasNewItems && hadItems && !isUserScrolledUp) { - // Small delay to ensure DOM is updated + if (hasNewItems && !isUserScrolledUp) { setTimeout(scrollToBottom, 100); } - lastMessageCountRef.current = currentTimelineCount; + lastMessageCountRef.current = currentCount; }, [timeline.length, isUserScrolledUp, scrollToBottom]); - // Scroll to bottom on initial load - React.useEffect(() => { - if (timeline.length > 0 && lastMessageCountRef.current === 0) { - setTimeout(scrollToBottom, 100); - } - }, [timeline.length, scrollToBottom]); - const { currentWorkflowId, isLoading, error } = workflowState; + const isEmpty = timeline.length === 0 && !isLoading && !isTransforming && !currentWorkflowId; + return (
{error && ( @@ -565,28 +179,25 @@ const MessageList: React.FC = ({ Error: {error}
)} -
{timeline.map((timelineItem, index) => { - if (timelineItem.type === 'message') { - const message = timelineItem.item; + const { type, item } = timelineItem; + if (type === 'message') { return ( ); - } else if (timelineItem.type === 'log') { - const log = timelineItem.item; - + } else if (type === 'log') { return ( ); @@ -595,82 +206,41 @@ const MessageList: React.FC = ({ })}
- {(isLoading || isTransforming) && ( -
- {isTransforming ? 'Processing messages...' : 'Loading messages...'} -
- )} - - {timeline.length === 0 && !isLoading && !isTransforming && !currentWorkflowId && ( + {isEmpty && (
- No workflow selected. Start a conversation to create a new workflow. +
+ +
+

{t('chat.messages.no_workflow_selected')}

+

{t('chat.messages.no_workflow_selected_description')}

)} - - {timeline.length === 0 && !isLoading && !isTransforming && currentWorkflowId && ( -
- No messages or logs in this workflow yet. -
- )} -
- {/* Workflow Progress Bar - positioned outside scrollable area */} - {(workflowProgress || workflowState.logs?.length > 0) && ( + {/* Progress Bar */} + {showProgressBar && (
- {workflowProgress ? ( - <>
- Workflow Progress - - {workflowProgress.isLoading - ? 'Loading tasks...' - : `${workflowProgress.current}/${workflowProgress.total} Actions (${workflowProgress.percentage}%)` - } - -
- {workflowProgress.taskBreakdown && !workflowProgress.isLoading && ( -
- {workflowProgress.taskBreakdown} -
- )} -
-
-
- - ) : ( - <> -
- Workflow Progress - Analyzing workflow... ({workflowState.logs?.length || 0} logs) + {t('chat.messages.workflow_progress')} + {progressText}
- - )}
)} - {/* Scroll to bottom button - positioned relative to message list container */}
); diff --git a/src/components/Dashboard/DashboardChat/DashboardChatAreaStyles/DashboardChatMessages.module.css b/src/components/Dashboard/DashboardChat/DashboardChatAreaStyles/DashboardChatMessages.module.css index 78970cd..5d09d66 100644 --- a/src/components/Dashboard/DashboardChat/DashboardChatAreaStyles/DashboardChatMessages.module.css +++ b/src/components/Dashboard/DashboardChat/DashboardChatAreaStyles/DashboardChatMessages.module.css @@ -15,12 +15,44 @@ padding: 15px; border-radius: 15px; margin-bottom: 15px; - min-height: 0; + height: 100%; overflow-y: auto; overflow-x: hidden; scroll-behavior: smooth; } +.chat_messages_empty { + flex: 1; + padding: 15px; + border-radius: 15px; + margin-bottom: 15px; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + scroll-behavior: smooth; + display: flex; + justify-content: center; + align-items: center; +} +.message_empty_state_icon { + font-size: 90px; + color: var(--color-primary); +} + +.message_empty_state h3 { + font-family: var(--font-family); + font-size: 1.2rem; + font-weight: 500; + color: var(--color-text); +} + +.message_empty_state p { + font-family: var(--font-family); + font-size: 1rem; + font-weight: 400; + color: var(--color-text); +} + .chat_messages::-webkit-scrollbar { width: 6px; } @@ -308,6 +340,7 @@ .message_empty_state { color: var(--color-gray); text-align: center; + justify-content: center; padding: 16px; } diff --git a/src/components/Dashboard/DashboardChat/dashboardChatAreaProgressBar.ts b/src/components/Dashboard/DashboardChat/dashboardChatAreaProgressBar.ts new file mode 100644 index 0000000..d3aab75 --- /dev/null +++ b/src/components/Dashboard/DashboardChat/dashboardChatAreaProgressBar.ts @@ -0,0 +1,276 @@ +import { WorkflowLog, WorkflowProgress, TimelineItem } from './dashboardChatAreaTypes'; + +// Helper function to parse task and action progress from log messages +const parseLogProgress = (logMessage: string) => { + const message = logMessage.trim(); + + // Task start: "Executing task X/Y" + const taskStartMatch = message.match(/^Executing task (\d+)\/(\d+)$/i); + if (taskStartMatch) { + return { + taskNumber: parseInt(taskStartMatch[1]), + totalTasks: parseInt(taskStartMatch[2]), + type: 'task_start' as const + }; + } + + // Action start: "Task X - Starting action Y/Z" + const actionStartMatch = message.match(/^Task (\d+) - Starting action (\d+)\/(\d+)$/i); + if (actionStartMatch) { + return { + taskNumber: parseInt(actionStartMatch[1]), + actionNumber: parseInt(actionStartMatch[2]), + totalActions: parseInt(actionStartMatch[3]), + type: 'action_start' as const + }; + } + + // Action complete: "✅ Task X - Action Y/Z completed" + const actionCompleteMatch = message.match(/^(?:✅\s+)?Task (\d+) - Action (\d+)\/(\d+) completed$/i); + if (actionCompleteMatch) { + return { + taskNumber: parseInt(actionCompleteMatch[1]), + actionNumber: parseInt(actionCompleteMatch[2]), + totalActions: parseInt(actionCompleteMatch[3]), + isCompleted: true, + type: 'action_complete' as const + }; + } + + // Task complete: "🎯 Task X/Y completed" + const taskCompleteMatch = message.match(/^(?:🎯\s+)?Task (\d+)\/(\d+) completed$/i); + if (taskCompleteMatch) { + return { + taskNumber: parseInt(taskCompleteMatch[1]), + totalTasks: parseInt(taskCompleteMatch[2]), + isCompleted: true, + type: 'task_complete' as const + }; + } + + return { type: 'unknown' as const }; +}; + +// Calculate workflow progress from messages and logs +export const calculateWorkflowProgress = (messages: any[], logs: WorkflowLog[] = []): WorkflowProgress | null => { + if (messages.length === 0 && logs.length === 0) return null; + + // Check if waiting for assistant response + const lastMessage = messages[messages.length - 1]; + if (lastMessage?.role === 'user') { + return { current: 0, total: 0, percentage: 0, isLoading: true }; + } + + // Parse logs for task structure + let totalTasks = 0; + const taskActionCounts: { [taskNumber: number]: { total: number; completedActions: Set } } = {}; + + // Analyze logs + logs.forEach((log) => { + if (!log.message) return; + + const progress = parseLogProgress(log.message); + + if (progress.type === 'task_start' && progress.totalTasks) { + totalTasks = Math.max(totalTasks, progress.totalTasks); + } + + if (progress.type === 'action_start' && progress.taskNumber && progress.totalActions) { + const taskNum = progress.taskNumber; + if (!taskActionCounts[taskNum]) { + taskActionCounts[taskNum] = { total: 0, completedActions: new Set() }; + } + taskActionCounts[taskNum].total = Math.max(taskActionCounts[taskNum].total, progress.totalActions); + } + + if (progress.type === 'action_complete' && progress.taskNumber && progress.actionNumber && progress.totalActions) { + const taskNum = progress.taskNumber; + if (!taskActionCounts[taskNum]) { + taskActionCounts[taskNum] = { total: progress.totalActions, completedActions: new Set() }; + } + taskActionCounts[taskNum].completedActions.add(progress.actionNumber); + taskActionCounts[taskNum].total = Math.max(taskActionCounts[taskNum].total, progress.totalActions); + } + + if (progress.type === 'task_complete' && progress.taskNumber && progress.totalTasks) { + const taskNum = progress.taskNumber; + totalTasks = Math.max(totalTasks, progress.totalTasks); + // Mark all actions complete for this task + if (taskActionCounts[taskNum]) { + const task = taskActionCounts[taskNum]; + for (let i = 1; i <= task.total; i++) { + task.completedActions.add(i); + } + } + } + }); + + // Analyze messages for additional action completion patterns + messages.forEach((message) => { + if (message.role !== 'assistant' || !message.content) return; + + const actionCompleteMatch = message.content.match(/✅\s+Task (\d+) - Action\s+(?:(\d+)\/(\d+)|.*completed)/i); + if (actionCompleteMatch) { + const taskNumber = parseInt(actionCompleteMatch[1]); + + if (actionCompleteMatch[2] && actionCompleteMatch[3]) { + const actionNumber = parseInt(actionCompleteMatch[2]); + const totalActions = parseInt(actionCompleteMatch[3]); + + if (!taskActionCounts[taskNumber]) { + taskActionCounts[taskNumber] = { total: totalActions, completedActions: new Set() }; + } + taskActionCounts[taskNumber].completedActions.add(actionNumber); + taskActionCounts[taskNumber].total = Math.max(taskActionCounts[taskNumber].total, totalActions); + } else { + // Named action without numbers - treat as 1/1 + if (!taskActionCounts[taskNumber]) { + taskActionCounts[taskNumber] = { total: 1, completedActions: new Set() }; + } + taskActionCounts[taskNumber].completedActions.add(1); + if (taskActionCounts[taskNumber].total === 0) { + taskActionCounts[taskNumber].total = 1; + } + } + } + }); + + // Calculate completed tasks + let completedTasks = 0; + for (let taskNum = 1; taskNum <= totalTasks; taskNum++) { + const task = taskActionCounts[taskNum]; + if (task && task.total > 0 && task.completedActions.size === task.total) { + completedTasks++; + } + } + + if (totalTasks > 0) { + const percentage = Math.round((completedTasks / totalTasks) * 100); + return { + current: completedTasks, + total: totalTasks, + percentage: percentage, + isLoading: false + }; + } + + return null; +}; + +// Safe date parsing with fallback +export const safeParseDate = (timestamp: any, fallback: number = Date.now()): Date => { + if (!timestamp) return new Date(fallback); + + let dateToTry = timestamp; + + if (typeof timestamp === 'number') { + dateToTry = timestamp < 10000000000 ? timestamp * 1000 : timestamp; + } else if (typeof timestamp === 'string' && /^\d+$/.test(timestamp)) { + const numericTimestamp = parseInt(timestamp); + dateToTry = numericTimestamp < 10000000000 ? numericTimestamp * 1000 : numericTimestamp; + } else if (timestamp instanceof Date) { + dateToTry = timestamp; + } + + const date = new Date(dateToTry); + return isNaN(date.getTime()) ? new Date(fallback) : date; +}; + +// Merge and sort messages and logs by timestamp +export const mergeMessagesAndLogs = (messages: any[], logs: WorkflowLog[]): TimelineItem[] => { + const combined: TimelineItem[] = []; + + // Add messages + messages.forEach((message, index) => { + const rawTimestamp = message.timestamp || message.publishedAt; + const fallbackTime = Date.now() - ((messages.length + logs.length) - index) * 1000; + const timestamp = safeParseDate(rawTimestamp, fallbackTime); + + combined.push({ + type: 'message', + item: message, + timestamp + }); + }); + + // Add logs + logs.forEach((log, index) => { + const fallbackTime = Date.now() - ((logs.length) - index) * 1000; + const timestamp = safeParseDate(log.timestamp, fallbackTime); + + combined.push({ + type: 'log', + item: log, + timestamp + }); + }); + + // Sort by timestamp + combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + + return combined; +}; + +// Transform workflow message to display message +export const transformWorkflowMessage = async (workflowMessage: any, request: any): Promise => { + let documents: any[] = []; + + // Handle documents + if (workflowMessage.documents && workflowMessage.documents.length > 0) { + documents = workflowMessage.documents.map((doc: any) => ({ + id: doc.id || doc.fileId, + fileId: typeof doc.fileId === 'string' ? parseInt(doc.fileId) : doc.fileId, + name: doc.filename, + ext: doc.filename.split('.').pop() || 'unknown', + type: doc.mimeType, + size: doc.fileSize, + downloadUrl: `/api/workflows/files/${doc.fileId}/download` + })); + } else if (workflowMessage.fileIds && workflowMessage.fileIds.length > 0) { + // Legacy fileIds approach + const documentPromises = workflowMessage.fileIds.map(async (fileId: number) => { + try { + const response = await request({ + url: `/api/workflows/files/${fileId}/preview`, + method: 'get' + }); + + return { + id: fileId.toString(), + fileId: fileId, + name: response.name || response.fileName || `File_${fileId}`, + ext: response.extension || response.ext || (response.name ? response.name.split('.').pop() : 'txt'), + type: response.mimeType || response.type || 'application/octet-stream', + size: response.size || 0, + downloadUrl: response.downloadUrl || response.url + }; + } catch (error) { + return { + id: fileId.toString(), + fileId: fileId, + name: `File_${fileId}`, + ext: 'unknown', + type: 'application/octet-stream', + size: 0 + }; + } + }); + + documents = await Promise.all(documentPromises); + } + + const content = workflowMessage.message || + workflowMessage.content || + (workflowMessage as any).text || + (workflowMessage as any).body || + ''; + + return { + id: workflowMessage.id, + role: workflowMessage.role, + agentName: workflowMessage.role === 'user' ? 'You' : 'Assistant', + content: content, + timestamp: workflowMessage.publishedAt || workflowMessage.timestamp, + documents: documents + }; +}; diff --git a/src/components/Dashboard/DashboardChat/dashboardChatAreaTypes.ts b/src/components/Dashboard/DashboardChat/dashboardChatAreaTypes.ts index 1b29840..51fa05c 100644 --- a/src/components/Dashboard/DashboardChat/dashboardChatAreaTypes.ts +++ b/src/components/Dashboard/DashboardChat/dashboardChatAreaTypes.ts @@ -122,6 +122,7 @@ export interface WorkflowState { currentWorkflowId: string | null; workflow: Workflow | null; messages: WorkflowMessage[]; + pendingMessages: WorkflowMessage[]; // Messages sent but not yet confirmed by backend logs: WorkflowLog[]; isLoading: boolean; error: string | null; @@ -165,4 +166,42 @@ export interface Message { content: string; timestamp?: string; documents?: Document[]; -} \ No newline at end of file +} + +export interface InputAreaProps { + selectedPrompt?: Prompt | null; + workflowState: WorkflowState; + workflowActions: WorkflowActions; + attachedFiles?: AttachedFile[]; + onAttachedFilesChange?: (files: AttachedFile[]) => void; + } + + export interface AttachedFile { + id: number; + name: string; + size: number; + type: string; + fileData?: File; + objectUrl?: string; + } + + export interface MessageListProps { + workflowState: WorkflowState; + onFilePreview?: (file: any) => void; + } + + // Progress bar interfaces + export interface WorkflowProgress { + current: number; + total: number; + percentage: number; + isLoading: boolean; + taskBreakdown?: string; + } + + // Timeline item for merged messages and logs + export interface TimelineItem { + type: 'message' | 'log'; + item: any; + timestamp: Date; + } \ No newline at end of file diff --git a/src/components/Dashboard/DashboardChat/useWorkflowManager.ts b/src/components/Dashboard/DashboardChat/useWorkflowManager.ts index a244979..4f9b1f3 100644 --- a/src/components/Dashboard/DashboardChat/useWorkflowManager.ts +++ b/src/components/Dashboard/DashboardChat/useWorkflowManager.ts @@ -6,6 +6,8 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow // Core state const [currentWorkflowId, setCurrentWorkflowId] = useState(initialWorkflowId || null); const [isPolling, setIsPolling] = useState(false); + const [pendingMessages, setPendingMessages] = useState([]); + const [sentUserMessages, setSentUserMessages] = useState>(new Set()); // Track sent user messages const pollingIntervalRef = useRef(null); // Hook-based data fetching @@ -18,12 +20,40 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow // Use status for real-time updates, fallback to workflow for initial data const currentWorkflow = workflowStatus || workflow; - + // Helper to create optimistic user message + const createOptimisticMessage = useCallback((prompt: string, fileIds: number[] = []) => { + const timestamp = new Date().toISOString(); + return { + id: `temp-${Date.now()}`, + workflowId: currentWorkflowId || 'pending', + message: prompt, + role: 'user' as const, + status: 'pending', + sequenceNr: 0, + publishedAt: timestamp, + success: true, + fileIds: fileIds.length > 0 ? fileIds : undefined + }; + }, [currentWorkflowId]); // Combined loading and error states const isLoading = workflowLoading || statusLoading || messagesLoading || logsLoading; const error = workflowError || statusError || messagesError || logsError; + // Filter out ALL user messages from backend - we only show user messages from pendingMessages + const filteredMessages = useMemo(() => { + if (!messages) return []; + + // If we've tracked ANY sent messages, always filter out user messages from backend + // This prevents flickering during new workflow creation + if (sentUserMessages.size > 0) { + return messages.filter(msg => msg.role !== 'user'); + } + + // If no tracked sent messages, this is a historical workflow - show all messages + return messages; + }, [messages, sentUserMessages]); + // Auto-polling for active workflows and message updates useEffect(() => { if (isPolling && currentWorkflowId) { @@ -53,6 +83,13 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow }, []); const startNewWorkflow = useCallback(async (prompt: string, fileIds: number[] = []): Promise => { + // Add optimistic message immediately + const optimisticMessage = createOptimisticMessage(prompt, fileIds); + setPendingMessages(prev => [...prev, optimisticMessage]); + + // Track this message as sent + setSentUserMessages(prev => new Set(prev).add(prompt.trim())); + const workflowData: StartWorkflowRequest = { prompt, listFileId: fileIds @@ -69,13 +106,28 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow refetchLogs(); }, 500); return newWorkflowId; + } else { + // Remove optimistic message and sent tracking on failure + setPendingMessages(prev => prev.filter(msg => msg.id !== optimisticMessage.id)); + setSentUserMessages(prev => { + const newSet = new Set(prev); + newSet.delete(prompt.trim()); + return newSet; + }); } return null; - }, [startWorkflow, refetchMessages]); + }, [startWorkflow, refetchMessages, createOptimisticMessage]); const continueWorkflow = useCallback(async (prompt: string, fileIds: number[] = []): Promise => { if (!currentWorkflowId) return false; + // Add optimistic message immediately + const optimisticMessage = createOptimisticMessage(prompt, fileIds); + setPendingMessages(prev => [...prev, optimisticMessage]); + + // Track this message as sent + setSentUserMessages(prev => new Set(prev).add(prompt.trim())); + const workflowData: StartWorkflowRequest = { prompt, listFileId: fileIds @@ -90,9 +142,17 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow refetchLogs(); }, 500); return true; + } else { + // Remove optimistic message and sent tracking on failure + setPendingMessages(prev => prev.filter(msg => msg.id !== optimisticMessage.id)); + setSentUserMessages(prev => { + const newSet = new Set(prev); + newSet.delete(prompt.trim()); + return newSet; + }); } return false; - }, [currentWorkflowId, startWorkflow, refetchMessages, refetchLogs]); + }, [currentWorkflowId, startWorkflow, refetchMessages, refetchLogs, createOptimisticMessage]); const stopWorkflow = useCallback(async (): Promise => { if (!currentWorkflowId) return false; @@ -113,8 +173,32 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow const clearWorkflow = useCallback(() => { setCurrentWorkflowId(null); setIsPolling(false); + setPendingMessages([]); + setSentUserMessages(new Set()); }, []); + // Only clear pending messages when workflow changes to a different ID + // (not when creating a new workflow) + const previousWorkflowId = useRef(currentWorkflowId); + useEffect(() => { + const prev = previousWorkflowId.current; + const current = currentWorkflowId; + + // If we're switching between different existing workflows, clear state + if (prev && current && prev !== current) { + setPendingMessages([]); + setSentUserMessages(new Set()); + } + // If we're going from a workflow to no workflow, clear state + else if (prev && !current) { + setPendingMessages([]); + setSentUserMessages(new Set()); + } + // If we're going from no workflow to a workflow (new workflow creation), keep pending messages + + previousWorkflowId.current = current; + }, [currentWorkflowId]); + // Sync with external workflow ID changes useEffect(() => { if (initialWorkflowId !== currentWorkflowId) { @@ -140,8 +224,9 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow const state: WorkflowState = { currentWorkflowId, workflow: currentWorkflow, - messages, - logs, + messages: filteredMessages, + pendingMessages, + logs: logs || [], isLoading, error }; diff --git a/src/locales/de.ts b/src/locales/de.ts index 6b859f0..860b3a2 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -232,7 +232,15 @@ export default { 'chat_history.resume_tooltip': 'Workflow fortsetzen', 'chat_history.delete_tooltip': 'Workflow löschen', 'chat_history.deleting': 'Workflow wird gelöscht...', - + + // Chat Messages + 'chat.messages.no_workflow_selected': 'Noch keinen Workflow ausgewählt', + 'chat.messages.no_workflow_selected_description': 'Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow', + 'chat.messages.loading_progress': 'Lade Fortschritt...', + 'chat.messages.tasks': 'Aufgaben', + 'chat.messages.workflow_progress': 'Workflow Fortschritt', + 'chat.messages.analyzing_workflow': 'Analysiere Workflow...', + 'chat.messages.scroll_to_bottom_btn': 'Nach unten scrollen', // Workflow Status 'status.error': 'FEHLER', 'status.failed': 'FEHLGESCHLAGEN', diff --git a/src/locales/en.ts b/src/locales/en.ts index 62a5ed9..80be524 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -234,6 +234,14 @@ export default { 'chat_history.delete_tooltip': 'Delete workflow', 'chat_history.deleting': 'Deleting workflow...', + // Chat Messages + 'chat.messages.no_workflow_selected': 'No workflow selected', + 'chat.messages.no_workflow_selected_description': 'Select a workflow from the list or start a new workflow', + 'chat.messages.loading_progress': 'Loading progress...', + 'chat.messages.tasks': 'Tasks', + 'chat.messages.workflow_progress': 'Workflow Progress', + 'chat.messages.analyzing_workflow': 'Analyzing workflow...', + 'chat.messages.scroll_to_bottom_btn': 'Scroll to bottom', // Workflow Status 'status.error': 'ERROR', 'status.failed': 'FAILED', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 47c44f6..3d9496e 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -233,6 +233,15 @@ export default { 'chat_history.delete_tooltip': 'Supprimer le workflow', 'chat_history.deleting': 'Suppression du workflow...', + // Chat Messages + 'chat.messages.no_workflow_selected': 'Aucun workflow sélectionné', + 'chat.messages.no_workflow_selected_description': 'Sélectionnez un workflow dans la liste ou démarrez un nouveau workflow', + 'chat.messages.loading_progress': 'Chargement du progrès...', + 'chat.messages.tasks': 'Tâches', + 'chat.messages.workflow_progress': 'Progression du workflow', + 'chat.messages.analyzing_workflow': 'Analyse du workflow...', + 'chat.messages.scroll_to_bottom_btn': 'Faire défiler vers le bas', + // Workflow Status 'status.error': 'ERREUR', 'status.failed': 'ÉCHEC',