smoother progress bar and message rendering

This commit is contained in:
Ida Dittrich 2025-08-21 10:25:53 +02:00
parent 18d03e4e78
commit 574e3e1cfa
10 changed files with 586 additions and 613 deletions

View file

@ -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<InputAreaProps> = ({
selectedPrompt,
@ -91,12 +76,8 @@ const InputArea: React.FC<InputAreaProps> = ({
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;
}

View file

@ -106,42 +106,6 @@ const LogItem: React.FC<LogItemProps> = ({ log }) => {
Progress: {log.progress}%
</div>
)}
{(log.details || log.performance) && (
<>
<button
onClick={() => setShowDetails(!showDetails)}
style={{
background: 'none',
border: 'none',
color: 'var(--color-secondary)',
cursor: 'pointer',
fontSize: '11px',
padding: '4px 0',
textDecoration: 'underline'
}}
>
{showDetails ? 'Hide Details' : 'Show Details'}
</button>
{showDetails && (
<div className={messageStyles.log_details}>
{log.details && (
<div>
<strong>Details:</strong>
<pre>{JSON.stringify(log.details, null, 2)}</pre>
</div>
)}
{log.performance && (
<div>
<strong>Performance:</strong>
<pre>{JSON.stringify(log.performance, null, 2)}</pre>
</div>
)}
</div>
)}
</>
)}
</div>
);
};

View file

@ -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<number> } } = {};
// 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<any> => {
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<MessageListProps> = ({
workflowState,
onFilePreview
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [transformedMessages, setTransformedMessages] = React.useState<any[]>([]);
const [isTransforming, setIsTransforming] = React.useState(false);
const scrollContainerRef = React.useRef<HTMLDivElement>(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<MessageListProps> = ({
// 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<MessageListProps> = ({
};
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<MessageListProps> = ({
}
}, []);
// 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 (
<div className={messageStyles.message_list_container}>
<div
ref={scrollContainerRef}
className={messageStyles.chat_messages}
className={isEmpty ? messageStyles.chat_messages_empty : messageStyles.chat_messages}
onScroll={checkScrollPosition}
>
{error && (
@ -565,28 +179,25 @@ const MessageList: React.FC<MessageListProps> = ({
Error: {error}
</div>
)}
<div className={messageStyles.messages_container}>
{timeline.map((timelineItem, index) => {
if (timelineItem.type === 'message') {
const message = timelineItem.item;
const { type, item } = timelineItem;
if (type === 'message') {
return (
<MessageItem
key={`message-${message.id}`}
message={message}
key={`message-${item.id}`}
message={item}
index={index}
onFilePreview={onFilePreview}
/>
);
} else if (timelineItem.type === 'log') {
const log = timelineItem.item;
} else if (type === 'log') {
return (
<LogItem
key={`log-${log.id}`}
log={log}
key={`log-${item.id}`}
log={item}
index={index}
/>
);
@ -595,82 +206,41 @@ const MessageList: React.FC<MessageListProps> = ({
})}
</div>
{(isLoading || isTransforming) && (
<div className={messageStyles.message_loading}>
{isTransforming ? 'Processing messages...' : 'Loading messages...'}
</div>
)}
{timeline.length === 0 && !isLoading && !isTransforming && !currentWorkflowId && (
{isEmpty && (
<div className={messageStyles.message_empty_state}>
No workflow selected. Start a conversation to create a new workflow.
<div className={messageStyles.message_empty_state_icon}>
<IoIosChatbubbles />
</div>
<h3>{t('chat.messages.no_workflow_selected')}</h3>
<p>{t('chat.messages.no_workflow_selected_description')}</p>
</div>
)}
{timeline.length === 0 && !isLoading && !isTransforming && currentWorkflowId && (
<div className={messageStyles.message_empty_state}>
No messages or logs in this workflow yet.
</div>
)}
</div>
{/* Workflow Progress Bar - positioned outside scrollable area */}
{(workflowProgress || workflowState.logs?.length > 0) && (
{/* Progress Bar */}
{showProgressBar && (
<div className={messageStyles.workflow_progress_container}>
{workflowProgress ? (
<>
<div className={messageStyles.workflow_progress_label}>
<span>Workflow Progress</span>
<span>
{workflowProgress.isLoading
? 'Loading tasks...'
: `${workflowProgress.current}/${workflowProgress.total} Actions (${workflowProgress.percentage}%)`
}
</span>
</div>
{workflowProgress.taskBreakdown && !workflowProgress.isLoading && (
<div className={messageStyles.workflow_progress_breakdown}>
{workflowProgress.taskBreakdown}
</div>
)}
<div className={messageStyles.workflow_progress_bar}>
<div
className={`${messageStyles.workflow_progress_fill} ${
workflowProgress.isLoading ? messageStyles.loading : ''
}`}
style={{ width: workflowProgress.isLoading ? '0%' : `${workflowProgress.percentage}%` }}
/>
</div>
</>
) : (
<>
<div className={messageStyles.workflow_progress_label}>
<span>Workflow Progress</span>
<span>Analyzing workflow... ({workflowState.logs?.length || 0} logs)</span>
<span>{t('chat.messages.workflow_progress')}</span>
<span>{progressText}</span>
</div>
<div className={messageStyles.workflow_progress_bar}>
<div
className={`${messageStyles.workflow_progress_fill} ${messageStyles.loading}`}
style={{ width: '0%' }}
className={messageStyles.workflow_progress_fill}
style={{ width: `${progressPercentage}%` }}
/>
</div>
</>
)}
</div>
)}
{/* Scroll to bottom button - positioned relative to message list container */}
<button
className={`${messageStyles.scroll_to_bottom_btn} ${
(isUserScrolledUp && timeline.length > 0)
? messageStyles.visible
: messageStyles.hidden
(isUserScrolledUp && timeline.length > 0) ? messageStyles.visible : messageStyles.hidden
}`}
onClick={scrollToBottom}
title="Scroll to bottom"
title={t('chat.messages.scroll_to_bottom_btn')}
>
<IoIosArrowDown className={messageStyles.scroll_to_bottom_btn_icon} />
</button>
</div>
);

View file

@ -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;
}

View file

@ -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<number> } } = {};
// 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<any> => {
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
};
};

View file

@ -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[];
}
}
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;
}

View file

@ -6,6 +6,8 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
// Core state
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(initialWorkflowId || null);
const [isPolling, setIsPolling] = useState(false);
const [pendingMessages, setPendingMessages] = useState<any[]>([]);
const [sentUserMessages, setSentUserMessages] = useState<Set<string>>(new Set()); // Track sent user messages
const pollingIntervalRef = useRef<number | null>(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<string | null> => {
// 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<boolean> => {
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<boolean> => {
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
};

View file

@ -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',

View file

@ -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',

View file

@ -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',